Polls: Redesign modal (#6886)

This commit is contained in:
zubiden 2026-05-05 13:46:45 +02:00 committed by Alexander Zinchuk
parent 6ceb7b6573
commit a2d6d63853
56 changed files with 4002 additions and 1575 deletions

View File

@ -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. - **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. - **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. - Prefer rem units for all measurements. Exceptions are possible, but usually rare.
- No complex or broad selectors. Prefer basic classes.
- **Code Style:** - **Code Style:**
- Early returns. - Early returns.
@ -176,7 +177,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => {
* **StateProps**: data injected by `withGlobal` HOC * **StateProps**: data injected by `withGlobal` HOC
* Merge them as `OwnProps & StateProps` when defining your component. * Merge them as `OwnProps & StateProps` when defining your component.
* You can skip one or both if they are not used. * 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. * Do not pass unmemoized objects as props into memo() components.
### 3. Hooks ### 3. Hooks

View File

@ -41,8 +41,8 @@
"npm": "^10.8 || ^11" "npm": "^10.8 || ^11"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js}": "eslint --fix", "*.{ts,tsx,js}": "eslint --cache --cache-location .cache/.eslintcache --fix",
"*.{css,scss}": "stylelint --fix" "*.{css,scss}": "stylelint --cache --cache-location .cache/.stylelintcache --fix"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",

View File

@ -118,6 +118,7 @@ export interface GramJsAppConfig extends LimitsConfig {
ton_usd_rate?: number; ton_usd_rate?: number;
ton_topup_url?: string; ton_topup_url?: string;
poll_answers_max?: number; poll_answers_max?: number;
poll_close_period_max?: number;
todo_items_max?: number; todo_items_max?: number;
todo_title_length_max?: number; todo_title_length_max?: number;
todo_item_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, tonUsdRate: appConfig.ton_usd_rate,
tonTopupUrl: appConfig.ton_topup_url, tonTopupUrl: appConfig.ton_topup_url,
pollMaxAnswers: appConfig.poll_answers_max, pollMaxAnswers: appConfig.poll_answers_max,
pollClosePeriodMax: appConfig.poll_close_period_max,
todoItemsMax: appConfig.todo_items_max, todoItemsMax: appConfig.todo_items_max,
todoTitleLengthMax: appConfig.todo_title_length_max, todoTitleLengthMax: appConfig.todo_title_length_max,
todoItemLengthMax: appConfig.todo_item_length_max, todoItemLengthMax: appConfig.todo_item_length_max,

View File

@ -235,18 +235,24 @@ export function buildInputPoll(
}); });
}), }),
quiz: poll.isQuiz, quiz: poll.isQuiz,
closeDate: poll.closeDate,
closePeriod: poll.closePeriod,
hideResultsUntilClose: poll.shouldHideResultsUntilClose,
revotingDisabled: poll.isRevoteDisabled,
shuffleAnswers: poll.shouldShuffleAnswers,
openAnswers: poll.canAddAnswers,
multipleChoice: poll.isMultipleChoice, multipleChoice: poll.isMultipleChoice,
hash: DEFAULT_PRIMITIVES.BIGINT, hash: DEFAULT_PRIMITIVES.BIGINT,
}); });
const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity); const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity) || [];
return new GramJs.InputMediaPoll({ return new GramJs.InputMediaPoll({
poll: inputPoll, poll: inputPoll,
correctAnswers, correctAnswers,
attachedMedia: media?.attachedMedia, attachedMedia: media?.attachedMedia,
solution, solution,
solutionEntities: inputSolutionEntities, solutionEntities: solution ? inputSolutionEntities : undefined,
solutionMedia: media?.solutionMedia, solutionMedia: media?.solutionMedia,
}); });
} }

View File

@ -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({ export async function toggleTodoCompleted({
chat, messageId, completedIds, incompletedIds, chat, messageId, completedIds, incompletedIds,
}: { }: {

View File

@ -320,7 +320,8 @@ export interface ApiAppConfig {
tonStargiftResaleCommissionPermille?: number; tonStargiftResaleCommissionPermille?: number;
tonUsdRate?: number; tonUsdRate?: number;
tonTopupUrl: string; tonTopupUrl: string;
pollMaxAnswers?: number; pollMaxAnswers: number;
pollClosePeriodMax: number;
todoItemsMax: number; todoItemsMax: number;
todoTitleLengthMax: number; todoTitleLengthMax: number;
todoItemLengthMax: number; todoItemLengthMax: number;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="M22.622 1.003H9.381A8.38 8.38 0 0 0 1 9.383v13.24a8.38 8.38 0 0 0 8.38 8.38h13.24a8.38 8.38 0 0 0 8.38-8.38V9.38A8.38 8.38 0 0 0 22.62 1m.94 9.8-8.668 12.386a.792.792 0 0 1-1.209.104l-5.078-5.077a1.478 1.478 0 1 1 2.09-2.091l3.254 3.256 7.19-10.272a1.478 1.478 0 1 1 2.42 1.695"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="11.497" height="11.497" x="1" y="4.834" rx="1.791" ry="1.791"/><rect width="8.069" height="8.069" x="4.428" y="19.272" rx="1.791" ry="1.791"/><rect width="9.798" height="9.798" x="15.67" y="19.272" rx="1.791" ry="1.791"/><path d="M30.21 0H17.46c-.984 0-1.79.806-1.79 1.79v12.748c0 .985.806 1.79 1.79 1.79h12.75c.984 0 1.79-.805 1.79-1.79V1.791C32 .806 31.194 0 30.21 0m-.804 4.857-5.993 8.56a.94.94 0 0 1-1.435.127L18.43 9.997a1.492 1.492 0 1 1 2.11-2.11l1.826 1.823 4.596-6.564a1.492 1.492 0 1 1 2.444 1.711"/></svg>

After

Width:  |  Height:  |  Size: 593 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><circle cx="7.87" cy="7.87" r="6.87"/><path d="M21.206 4.129a2.56 2.56 0 0 1 2.557 2.558v3.061l-2.237-2.237a1.05 1.05 0 0 0-1.49 0l-.453.454a1.05 1.05 0 0 0 0 1.49l4.847 4.847a1.003 1.003 0 0 0 1.415 0l4.846-4.848a1.05 1.05 0 0 0 0-1.49l-.454-.453a1.05 1.05 0 0 0-1.489 0l-2.237 2.237V6.687a5.304 5.304 0 0 0-5.305-5.305h-3.443a1.054 1.054 0 0 0-1.054 1.052v.642c0 .583.471 1.054 1.054 1.054z"/><circle cx="24.13" cy="24.212" r="6.87"/><path d="M14.237 27.954h-3.443a2.556 2.556 0 0 1-2.557-2.559v-3.06l2.237 2.236a1.05 1.05 0 0 0 1.49 0l.453-.453a1.05 1.05 0 0 0 0-1.49L7.57 17.781a1.003 1.003 0 0 0-1.415 0L1.31 22.628a1.05 1.05 0 0 0 0 1.49l.454.453a1.05 1.05 0 0 0 1.489 0l2.237-2.237v3.061a5.304 5.304 0 0 0 5.305 5.305h3.443a1.054 1.054 0 0 0 1.054-1.052v-.642a1.054 1.054 0 0 0-1.054-1.054"/></svg>

After

Width:  |  Height:  |  Size: 868 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><path d="m26.432 9.472 1.757-1.757a1.362 1.362 0 1 0-1.926-1.925l-1.724 1.724a13.2 13.2 0 0 0-7.177-3.04v-2.11a1.362 1.362 0 1 0-2.724 0v2.11c-6.704.68-11.937 6.345-11.937 13.23C2.701 25.045 8.655 31 16 31s13.299-5.954 13.299-13.297c0-3.112-1.078-5.965-2.867-8.231M16 20.154a2.451 2.451 0 0 1-1.362-4.488v-6.04a1.362 1.362 0 1 1 2.724 0v6.038A2.451 2.451 0 0 1 16 20.154"/></svg>

After

Width:  |  Height:  |  Size: 442 B

View File

@ -685,17 +685,30 @@
"Message" = "Message"; "Message" = "Message";
"RecentStickers" = "Recently Used"; "RecentStickers" = "Recently Used";
"PollsChooseQuestion" = "Please enter a question."; "PollsChooseQuestion" = "Please enter a question.";
"PollsChooseAnswers" = "Please enter at least two options."; "PollsChooseAnswers" = "Please enter at least one option.";
"NewPoll" = "New Poll"; "NewPoll" = "New Poll";
"Create" = "Create"; "Create" = "Create";
"OptionHint" = "Option"; "OptionHint" = "Option";
"CreatePollAddOption" = "Add an Option"; "CreatePollAddOption" = "Add an Option";
"PollsChooseCorrect" = "Please choose the correct answer."; "PollsChooseCorrect" = "Please choose the correct answer.";
"AskAQuestion" = "Ask a Question"; "AskAQuestion" = "Ask a Question";
"PollOptions" = "Poll options"; "PollAnswersVisible" = "Show Who Voted";
"PollAnonymous" = "Anonymous Poll"; "PollAnswersVisibleDescription" = "Display voter name on each option";
"PollMultiple" = "Multiple Answers"; "PollMultiple" = "Allow Multiple Answers";
"PollQuiz" = "Quiz Mode"; "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"; "PollsSolutionTitle" = "Explanation";
"CreatePollExplanationInfo" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; "CreatePollExplanationInfo" = "Users will see this comment after choosing a wrong answer, good for educational purposes.";
"VoipGroupPersonalAccount" = "personal account"; "VoipGroupPersonalAccount" = "personal account";
@ -2888,3 +2901,10 @@
"AiMessageEditorTo" = "To"; "AiMessageEditorTo" = "To";
"ButtonHelp" = "Help"; "ButtonHelp" = "Help";
"UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure."; "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";

View File

@ -75,7 +75,7 @@ export { default as AiMessageEditorModal }
from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal'; from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal';
export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; 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 ToDoListModal } from '../components/middle/composer/ToDoListModal';
export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu'; export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu';
export { default as ChatCommandTooltip } from '../components/middle/composer/ChatCommandTooltip'; export { default as ChatCommandTooltip } from '../components/middle/composer/ChatCommandTooltip';

View File

@ -382,6 +382,7 @@ const CalendarModal = ({
onEnter={handleSubmit} onEnter={handleSubmit}
dialogRef={dialogRef} dialogRef={dialogRef}
hasAbsoluteCloseButton hasAbsoluteCloseButton
isNativeDialog
> >
<div className="container"> <div className="container">
<div className="month-selector"> <div className="month-selector">

View File

@ -19,7 +19,6 @@ import type {
ApiMessage, ApiMessage,
ApiMessageEntity, ApiMessageEntity,
ApiNewMediaTodo, ApiNewMediaTodo,
ApiNewPoll,
ApiPeer, ApiPeer,
ApiQuickReply, ApiQuickReply,
ApiReaction, ApiReaction,
@ -189,7 +188,6 @@ import EmojiTooltip from '../middle/composer/EmojiTooltip.async';
import InlineBotTooltip from '../middle/composer/InlineBotTooltip.async'; import InlineBotTooltip from '../middle/composer/InlineBotTooltip.async';
import MentionTooltip from '../middle/composer/MentionTooltip.async'; import MentionTooltip from '../middle/composer/MentionTooltip.async';
import MessageInput from '../middle/composer/MessageInput'; import MessageInput from '../middle/composer/MessageInput';
import PollModal from '../middle/composer/PollModal.async';
import SendAsMenu from '../middle/composer/SendAsMenu.async'; import SendAsMenu from '../middle/composer/SendAsMenu.async';
import StickerTooltip from '../middle/composer/StickerTooltip.async'; import StickerTooltip from '../middle/composer/StickerTooltip.async';
import SymbolMenuButton from '../middle/composer/SymbolMenuButton'; import SymbolMenuButton from '../middle/composer/SymbolMenuButton';
@ -253,7 +251,6 @@ type StateProps = {
isReplying?: boolean; isReplying?: boolean;
hasSuggestedPost?: boolean; hasSuggestedPost?: boolean;
forwardedMessagesCount?: number; forwardedMessagesCount?: number;
pollModal: TabState['pollModal'];
todoListModal: TabState['todoListModal']; todoListModal: TabState['todoListModal'];
aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult']; aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult'];
botKeyboardMessageId?: number; botKeyboardMessageId?: number;
@ -321,7 +318,6 @@ type StateProps = {
isAccountFrozen?: boolean; isAccountFrozen?: boolean;
isAppConfigLoaded?: boolean; isAppConfigLoaded?: boolean;
insertingPeerIdMention?: string; insertingPeerIdMention?: string;
pollMaxAnswers?: number;
replyToMessage?: ApiMessage; replyToMessage?: ApiMessage;
shouldOpenMessageMediaEditor?: TabState['shouldOpenMessageMediaEditor']; shouldOpenMessageMediaEditor?: TabState['shouldOpenMessageMediaEditor'];
}; };
@ -382,7 +378,6 @@ const Composer = ({
isReplying, isReplying,
hasSuggestedPost, hasSuggestedPost,
forwardedMessagesCount, forwardedMessagesCount,
pollModal,
todoListModal, todoListModal,
aiMessageEditorPendingResult, aiMessageEditorPendingResult,
botKeyboardMessageId, botKeyboardMessageId,
@ -449,7 +444,6 @@ const Composer = ({
isAccountFrozen, isAccountFrozen,
isAppConfigLoaded, isAppConfigLoaded,
insertingPeerIdMention, insertingPeerIdMention,
pollMaxAnswers,
replyToMessage, replyToMessage,
shouldOpenMessageMediaEditor, shouldOpenMessageMediaEditor,
onDropHide, onDropHide,
@ -462,8 +456,6 @@ const Composer = ({
clearDraft, clearDraft,
saveDraft, saveDraft,
showDialog, showDialog,
openPollModal,
closePollModal,
openTodoListModal, openTodoListModal,
closeTodoListModal, closeTodoListModal,
openAiMessageEditorModal, 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) => { const handleToDoListSend = useLastCallback((todo: ApiNewMediaTodo) => {
if (!currentMessageList) { if (!currentMessageList) {
return; return;
@ -2180,14 +2147,6 @@ const Composer = ({
canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled} canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled}
paidMessagesStars={paidMessagesStars} paidMessagesStars={paidMessagesStars}
/> />
<PollModal
isOpen={pollModal.isOpen}
isQuiz={pollModal.isQuiz}
shouldBeAnonymous={isChannel}
maxOptionsCount={pollMaxAnswers}
onClear={closePollModal}
onSend={handlePollSend}
/>
<ToDoListModal <ToDoListModal
modal={todoListModal} modal={todoListModal}
onClear={closeTodoListModal} onClear={closeTodoListModal}
@ -2505,7 +2464,6 @@ const Composer = ({
canInsertDate={!isComposerBlocked} canInsertDate={!isComposerBlocked}
onFileSelect={handleFileSelect} onFileSelect={handleFileSelect}
onDateInsert={handleFormattedDateInsert} onDateInsert={handleFormattedDateInsert}
onPollCreate={openPollModal}
onTodoListCreate={handleTodoListCreate} onTodoListCreate={handleTodoListCreate}
isScheduled={isInScheduledList} isScheduled={isInScheduledList}
attachBots={isInMessageList ? attachBots : undefined} attachBots={isInMessageList ? attachBots : undefined}
@ -2709,7 +2667,6 @@ export default memo(withGlobal<OwnProps>(
(global, { (global, {
chatId, threadId, storyId, messageListType, isMobile, type, chatId, threadId, storyId, messageListType, isMobile, type,
}): Complete<StateProps> => { }): Complete<StateProps> => {
const appConfig = global.appConfig;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const chatBot = !isSystemBot(chatId) ? selectBot(global, chatId) : undefined; const chatBot = !isSystemBot(chatId) ? selectBot(global, chatId) : undefined;
const isChatWithBot = Boolean(chatBot); const isChatWithBot = Boolean(chatBot);
@ -2824,7 +2781,6 @@ export default memo(withGlobal<OwnProps>(
isReplying, isReplying,
hasSuggestedPost, hasSuggestedPost,
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined, forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
pollModal: tabState.pollModal,
todoListModal: tabState.todoListModal, todoListModal: tabState.todoListModal,
aiMessageEditorPendingResult: tabState.aiMessageEditorPendingResult, aiMessageEditorPendingResult: tabState.aiMessageEditorPendingResult,
stickersForEmoji: global.stickers.forEmoji.stickers, stickersForEmoji: global.stickers.forEmoji.stickers,
@ -2883,7 +2839,9 @@ export default memo(withGlobal<OwnProps>(
shouldPaidMessageAutoApprove, shouldPaidMessageAutoApprove,
isSilentPosting, isSilentPosting,
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen
&& !tabState.aiMessageEditorModal, && !tabState.aiMessageEditorModal
&& !tabState.pollModal
&& !tabState.sharePreparedMessageModal,
starsBalance, starsBalance,
isStarsBalanceModalOpen, isStarsBalanceModalOpen,
shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton, shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton,
@ -2891,7 +2849,6 @@ export default memo(withGlobal<OwnProps>(
isAccountFrozen, isAccountFrozen,
isAppConfigLoaded, isAppConfigLoaded,
insertingPeerIdMention, insertingPeerIdMention,
pollMaxAnswers: appConfig.pollMaxAnswers,
shouldOpenMessageMediaEditor, shouldOpenMessageMediaEditor,
replyToMessage, replyToMessage,
}; };

View File

@ -1,89 +1,27 @@
@layer ui.layout { @layer ui.layout {
.control { .control {
--control-grid-template-areas: "input before label after";
--control-grid-columns: auto auto 1fr auto;
--control-gap: 1rem;
display: grid; display: grid;
grid-template-areas: "input label"; grid-template-areas: var(--control-grid-template-areas);
grid-template-columns: auto 1fr; grid-template-columns: var(--control-grid-columns);
flex-grow: 1; flex-grow: 1;
column-gap: 1rem; column-gap: 0;
align-items: center; 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 { &.inputEnd {
grid-template-areas: "label input"; --control-grid-template-areas: "before label after input";
grid-template-columns: 1fr auto; --control-grid-columns: auto 1fr auto 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";
}
}
} }
&:has(> .controlDescription) > .controlLabel { &:has(> .controlDescription) {
align-self: end; --control-grid-template-areas: "input before label after" "input before desc after";
}
&:has(> .controlDescription) > .input, &.inputEnd {
&:has(> .controlDescription) > .spinner { --control-grid-template-areas: "before label after input" "before desc after input";
transform: translateY(50%); }
grid-row: 1;
align-self: end;
} }
:global(label) { :global(label) {
@ -94,6 +32,12 @@
.input { .input {
grid-area: input; grid-area: input;
align-self: center; align-self: center;
margin-inline-end: var(--control-gap);
.inputEnd > & {
margin-inline-start: var(--control-gap);
margin-inline-end: 0;
}
} }
.spinner { .spinner {
@ -101,6 +45,12 @@
grid-area: input; grid-area: input;
align-self: center; align-self: center;
margin-inline-end: var(--control-gap);
.inputEnd > & {
margin-inline-start: var(--control-gap);
margin-inline-end: 0;
}
} }
.controlLabel { .controlLabel {
@ -130,10 +80,38 @@
.controlBefore { .controlBefore {
grid-area: before; grid-area: before;
align-self: center; align-self: center;
margin-inline-end: var(--control-gap);
} }
.controlAfter { .controlAfter {
grid-area: after; grid-area: after;
align-self: center; 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;
} }
} }

View File

@ -1,12 +1,15 @@
import type { TeactNode } from '../../../lib/teact/teact'; import type { TeactNode } from '../../../lib/teact/teact';
import { createContext, memo, useMemo } from '../../../lib/teact/teact'; import { createContext, memo, useMemo } from '../../../lib/teact/teact';
import type { IconName } from '../../../types/icons';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useContext from '../../../hooks/data/useContext'; import useContext from '../../../hooks/data/useContext';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useUniqueId from '../../../hooks/useUniqueId'; import useUniqueId from '../../../hooks/useUniqueId';
import Icon from '../../common/icons/Icon';
import Spinner from '../../ui/Spinner'; import Spinner from '../../ui/Spinner';
import { useInteractiveContext } from './Interactive'; import { useInteractiveContext } from './Interactive';
@ -123,6 +126,12 @@ type ControlSlotProps = {
children: TeactNode; children: TeactNode;
}; };
type ControlIconProps = {
iconName?: IconName;
className?: string;
backgroundColor?: string;
};
const ControlBefore = ({ className, children }: ControlSlotProps) => { const ControlBefore = ({ className, children }: ControlSlotProps) => {
return ( return (
<div className={buildClassName(styles.controlBefore, className)}> <div className={buildClassName(styles.controlBefore, className)}>
@ -139,6 +148,22 @@ const ControlAfter = ({ className, children }: ControlSlotProps) => {
); );
}; };
const ControlIcon = ({ iconName, className, backgroundColor }: ControlIconProps) => {
return (
<ControlBefore className={className}>
<div
className={buildClassName(
styles.controlIcon,
backgroundColor && styles.controlIconFilled,
)}
style={backgroundColor ? `--control-icon-background-color: ${backgroundColor};` : undefined}
>
{iconName && <Icon name={iconName} className={styles.controlIconGlyph} />}
</div>
</ControlBefore>
);
};
// #endregion // #endregion
export default memo(Control); export default memo(Control);
@ -147,4 +172,5 @@ export {
ControlDescription, ControlDescription,
ControlBefore, ControlBefore,
ControlAfter, ControlAfter,
ControlIcon,
}; };

View File

@ -10,6 +10,8 @@
margin: 0; margin: 0;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-default); border-radius: var(--border-radius-default);
transition: opacity 0.15s ease;
} }
.clickable { .clickable {

View File

@ -17,6 +17,18 @@
overflow-wrap: anywhere; 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 { .text {
display: block; display: block;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@ -35,4 +47,13 @@
.description + .island { .description + .island {
margin-top: 1rem; margin-top: 1rem;
} }
.title + .island {
margin-top: 0.5rem;
}
.description + .title,
.island + .title {
margin-top: 1rem;
}
} }

View File

@ -1,14 +1,18 @@
import type { ElementRef } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import styles from './Island.module.scss'; import styles from './Island.module.scss';
type OwnProps = React.HTMLAttributes<HTMLDivElement> & { type OwnProps = React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode; children: React.ReactNode;
ref?: ElementRef<HTMLDivElement>;
}; };
const Island = ({ className, children, ...otherProps }: OwnProps) => { const Island = ({ ref, className, children, ...otherProps }: OwnProps) => {
return ( return (
<div <div
ref={ref}
className={buildClassName(styles.island, className)} className={buildClassName(styles.island, className)}
{...otherProps} {...otherProps}
> >
@ -28,6 +32,17 @@ const IslandDescription = ({ className, children, ...otherProps }: OwnProps) =>
); );
}; };
const IslandTitle = ({ className, children, ...otherProps }: OwnProps) => {
return (
<div
className={buildClassName(styles.title, className)}
{...otherProps}
>
{children}
</div>
);
};
const IslandText = ({ className, children, ...otherProps }: OwnProps) => { const IslandText = ({ className, children, ...otherProps }: OwnProps) => {
return ( return (
<div <div
@ -42,5 +57,6 @@ const IslandText = ({ className, children, ...otherProps }: OwnProps) => {
export default Island; export default Island;
export { export {
IslandDescription, IslandDescription,
IslandTitle,
IslandText, IslandText,
}; };

View File

@ -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;
}
}
}

View File

@ -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<ModalContextType | undefined>(undefined);
const WIDTH_CLASS_NAME: Record<ModalWidth, string> = {
slim: styles.widthSlim,
regular: styles.widthRegular,
wide: styles.widthWide,
fullscreen: styles.widthFullscreen,
};
const HEIGHT_CLASS_NAME: Record<ModalHeight, string> = {
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<HTMLDialogElement>();
const panelRef = useRef<HTMLDivElement>();
const closeAnimationCleanupRef = useRef<NoneToVoidFunction>();
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<ModalContextType>(() => ({
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<HTMLDialogElement>) => {
if (
event.target !== event.currentTarget
|| noLightDismiss
|| !isOpen
|| isClosing
) {
return;
}
handleRequestClose();
});
if (!shouldRender) {
return undefined;
}
return (
<Portal>
<ModalContext.Provider value={contextValue}>
<dialog
ref={dialogRef}
className={buildClassName(
styles.dialog,
WIDTH_CLASS_NAME[frozenProps.width],
HEIGHT_CLASS_NAME[frozenProps.height],
frozenProps.noBackdrop && styles.noBackdrop,
(frozenProps.width === 'fullscreen' || frozenProps.height === 'fullscreen') && styles.fullscreen,
isClosing ? styles.closing : styles.open,
frozenProps.dialogClassName,
!frozenProps.noContainment && styles.contained,
)}
aria-modal="true"
aria-label={!hasTitle ? frozenProps.ariaLabel : undefined}
aria-labelledby={hasTitle ? titleId : undefined}
aria-describedby={hasSubtitle ? subtitleId : undefined}
onClick={handleDialogClick}
>
<div ref={panelRef} className={styles.panel}>
{shouldShowHeader && (
<div className={styles.headerSlot}>
{frozenProps.header}
</div>
)}
<Surface
scrollable
className={buildClassName(
styles.content,
shouldShowHeader && styles.withHeader,
frozenProps.contentClassName,
)}
>
<div className={styles.body}>
{frozenProps.children}
</div>
</Surface>
</div>
</dialog>
</ModalContext.Provider>
</Portal>
);
};
const ModalHeader = ({ className, children }: ModalSlotProps) => {
const modalContext = useModalContext();
return (
<div
className={buildClassName(
styles.header,
modalContext?.hasSubtitle && styles.headerWithSubtitle,
className,
)}
>
{children}
</div>
);
};
const ModalHeaderAction = ({ className, children }: ModalSlotProps) => {
return (
<div className={buildClassName(styles.headerAction, className)}>
{children}
</div>
);
};
const ModalTitle = ({ className, children }: ModalSlotProps) => {
const modalContext = useModalContext();
useLayoutEffect(() => {
modalContext?.registerTitle(true);
return () => {
modalContext?.registerTitle(false);
};
}, [modalContext]);
return (
<div
id={modalContext?.titleId}
className={buildClassName(styles.title, className)}
dir="auto"
>
{children}
</div>
);
};
const ModalSubtitle = ({ className, children }: ModalSlotProps) => {
const modalContext = useModalContext();
useLayoutEffect(() => {
modalContext?.registerSubtitle(true);
return () => {
modalContext?.registerSubtitle(false);
};
}, [modalContext]);
return (
<div
id={modalContext?.subtitleId}
className={buildClassName(styles.subtitle, className)}
dir="auto"
>
{children}
</div>
);
};
const ModalCloseButton = ({ asAbsolute, className }: ModalCloseButtonProps) => {
const lang = useLang();
const modalContext = useModalContext();
const handleClick = useLastCallback(() => {
modalContext?.onClose();
});
return (
<Button
round
color="translucent"
size="tiny"
ariaLabel={lang('Close')}
className={buildClassName(
styles.closeButton,
asAbsolute && styles.closeButtonAbsolute,
className,
)}
onClick={handleClick}
>
<div className="animated-close-icon" />
</Button>
);
};
export default memo(Modal);
export {
ModalHeader,
ModalHeaderAction,
ModalTitle,
ModalSubtitle,
ModalCloseButton,
};

View File

@ -2,79 +2,119 @@
.root { .root {
cursor: var(--custom-cursor, pointer); cursor: var(--custom-cursor, pointer);
position: relative;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center; align-items: center;
width: 1.875rem; width: 1.875rem;
height: 0.875rem; height: 0.875rem;
}
.input {
cursor: inherit;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0; 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; border-radius: 0.625rem;
appearance: none; background-color: var(--switch-track-color);
background-color: var(--ui-border-color, var(--color-borders-input));
transition: background-color 0.15s ease, border-color 0.15s ease; transition: background-color 0.15s ease, border-color 0.15s ease;
}
&::before { .thumb {
content: ""; 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; color: var(--switch-track-color);
height: 1.25rem;
border: 0.125rem solid var(--ui-border-color, var(--color-borders-input));
border-radius: 50%;
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 { .input {
border-color: var(--ui-accent-color, var(--color-primary)); &:checked + .track {
background-color: var(--ui-accent-color, var(--color-primary)); --switch-track-color: var(--ui-accent-color, var(--color-primary));
&::before { .thumb {
transform: translateX(0.75rem); transform: translateX(0.75rem);
border-color: var(--ui-accent-color, var(--color-primary));
} }
} }
&:disabled { &:focus-visible + .track {
cursor: default;
opacity: var(--input-disabled-opacity, 0.5);
}
&:focus-visible {
outline: 2px solid var(--ui-accent-color, var(--color-primary)); outline: 2px solid var(--ui-accent-color, var(--color-primary));
outline-offset: 2px; 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 { .permissionColors {
background-color: var(--color-error); .track {
--switch-track-color: var(--color-error);
&::before {
border-color: var(--color-error);
} }
&:checked { .input:checked + .track {
border-color: var(--color-green); --switch-track-color: var(--color-green);
background-color: var(--color-green);
&::before {
border-color: var(--color-green);
}
} }
&:focus-visible { .input:focus-visible + .track {
outline-color: var(--color-error); outline-color: var(--color-error);
} }
&:checked:focus-visible { .input:checked:focus-visible + .track {
outline-color: var(--color-green); outline-color: var(--color-green);
} }
} }

View File

@ -4,6 +4,8 @@ import buildClassName from '../../../util/buildClassName';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import Icon from '../../common/icons/Icon';
import ShowTransition from '../../ui/ShowTransition';
import { useControlContext } from '../layout/Control'; import { useControlContext } from '../layout/Control';
import { useInteractiveContext } from '../layout/Interactive'; import { useInteractiveContext } from '../layout/Interactive';
@ -17,6 +19,7 @@ type InputProps = React.DetailedHTMLProps<
type OwnProps = { type OwnProps = {
checked: boolean; checked: boolean;
disabled?: boolean; disabled?: boolean;
locked?: boolean;
withPermissionColors?: boolean; withPermissionColors?: boolean;
className?: string; className?: string;
onChange?: (checked: boolean) => void; onChange?: (checked: boolean) => void;
@ -27,6 +30,7 @@ type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
const Switch = ({ const Switch = ({
checked, checked,
disabled, disabled,
locked,
withPermissionColors, withPermissionColors,
className, className,
onChange, onChange,
@ -40,27 +44,42 @@ const Switch = ({
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading; const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (locked) return;
onChange?.(e.currentTarget.checked); onChange?.(e.currentTarget.checked);
}); });
if (interactive?.isLoading) return undefined; if (interactive?.isLoading) return undefined;
return ( return (
<input <span
{...restProps}
type="checkbox"
role="switch"
id={resolvedId}
checked={checked}
disabled={isDisabled}
className={buildClassName( className={buildClassName(
styles.root, styles.root,
isDisabled && styles.disabled,
locked && styles.locked,
withPermissionColors && styles.permissionColors, withPermissionColors && styles.permissionColors,
control?.inputClassName, control?.inputClassName,
className, className,
)} )}
onChange={handleChange} >
/> <input
{...restProps}
type="checkbox"
role="switch"
id={resolvedId}
checked={checked}
disabled={isDisabled || locked}
className={styles.input}
onChange={handleChange}
/>
<span className={styles.track} aria-hidden>
<span className={styles.thumb}>
<ShowTransition isOpen={Boolean(locked)} className={styles.lockIconTransition}>
<Icon name="lock-badge" className={styles.lockIcon} />
</ShowTransition>
</span>
</span>
</span>
); );
}; };

View File

@ -226,7 +226,10 @@ const MediaViewer = ({
const prevOrigin = usePrevious(origin); const prevOrigin = usePrevious(origin);
const prevItem = usePrevious(currentItem); const prevItem = usePrevious(currentItem);
const prevBestImageData = usePrevious(bestImageData); 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); const hasFooter = Boolean(textParts);
useEffectWithPrevDeps(([prevIsOpen, prevIsHidden]) => { useEffectWithPrevDeps(([prevIsOpen, prevIsHidden]) => {

View File

@ -57,12 +57,11 @@ export type OwnProps = {
theme: ThemeKey; theme: ThemeKey;
canEditMedia?: boolean; canEditMedia?: boolean;
editingMessage?: ApiMessage; editingMessage?: ApiMessage;
messageListType?: MessageListType; messageListType: MessageListType;
paidMessagesStars?: number; paidMessagesStars?: number;
canInsertDate?: boolean; canInsertDate?: boolean;
onFileSelect: (files: File[]) => void; onFileSelect: (files: File[]) => void;
onDateInsert: (text: ApiFormattedText) => void; onDateInsert: (text: ApiFormattedText) => void;
onPollCreate: NoneToVoidFunction;
onTodoListCreate: NoneToVoidFunction; onTodoListCreate: NoneToVoidFunction;
onMenuOpen: NoneToVoidFunction; onMenuOpen: NoneToVoidFunction;
onMenuClose: NoneToVoidFunction; onMenuClose: NoneToVoidFunction;
@ -93,10 +92,10 @@ const AttachMenu = ({
onDateInsert, onDateInsert,
onMenuOpen, onMenuOpen,
onMenuClose, onMenuClose,
onPollCreate,
onTodoListCreate, onTodoListCreate,
}: OwnProps) => { }: OwnProps) => {
const { const {
openPollModal,
updateAttachmentSettings, updateAttachmentSettings,
} = getActions(); } = getActions();
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag(); const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
@ -190,6 +189,11 @@ const AttachMenu = ({
openDateModal(); openDateModal();
}); });
const handlePollCreate = useLastCallback(() => {
closeAttachMenu();
openPollModal({ chatId, threadId, messageListType });
});
if (!isButtonVisible && !isDateModalOpen) { if (!isButtonVisible && !isDateModalOpen) {
return undefined; return undefined;
} }
@ -275,7 +279,7 @@ const AttachMenu = ({
</> </>
)} )}
{canAttachPolls && !editingMessage && ( {canAttachPolls && !editingMessage && (
<MenuItem icon="poll" onClick={onPollCreate}>{oldLang('Poll')}</MenuItem> <MenuItem icon="poll" onClick={handlePollCreate}>{lang('Poll')}</MenuItem>
)} )}
{canAttachToDoLists && !editingMessage && ( {canAttachToDoLists && !editingMessage && (
<MenuItem icon="select" onClick={onTodoListCreate}>{lang('TitleToDoList')}</MenuItem> <MenuItem icon="select" onClick={onTodoListCreate}>{lang('TitleToDoList')}</MenuItem>

View File

@ -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;
}
}
}

View File

@ -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<HTMLInputElement>();
const optionsListRef = useRef<HTMLDivElement>();
const [question, setQuestion] = useState<string>('');
const [options, setOptions] = useState<string[]>(['']);
const [isAnonymous, setIsAnonymous] = useState(true);
const [isMultipleAnswers, setIsMultipleAnswers] = useState(false);
const [isQuizMode, setIsQuizMode] = useState(isQuiz || false);
const [solution, setSolution] = useState<string>('');
const [correctOption, setCorrectOption] = useState<number | undefined>();
const [hasErrors, setHasErrors] = useState<boolean>(false);
const lang = useOldLang();
const handleSolutionChange = useLastCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setSolution(e.target.value);
});
const focusInput = useLastCallback((ref: ElementRef<HTMLInputElement>) => {
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<HTMLInputElement>) => {
setIsAnonymous(e.target.checked);
});
const handleMultipleAnswersChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setIsMultipleAnswers(e.target.checked);
});
const handleQuizModeChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setIsQuizMode(e.target.checked);
});
const handleKeyPress = useLastCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.keyCode === 13) {
handleCreate();
}
});
const handleQuestionChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
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 (
<div className="modal-header-condensed">
<Button
round
color="translucent"
size="tiny"
ariaLabel="Cancel poll creation"
onClick={onClear}
iconName="close"
/>
<div className="modal-title">{lang('NewPoll')}</div>
<Button
color="primary"
size="smaller"
className="modal-action-button"
onClick={handleCreate}
>
{lang('Create')}
</Button>
</div>
);
}
function renderOptions() {
return options.map((option, index) => (
<div className="option-wrapper">
<InputText
maxLength={MAX_OPTION_LENGTH}
label={index !== options.length - 1 || options.length === maxOptionsCount
? lang('OptionHint')
: lang('CreatePoll.AddOption')}
error={getOptionsError(index)}
value={option}
onChange={(e) => updateOption(index, e.currentTarget.value)}
onKeyPress={handleKeyPress}
/>
{index !== options.length - 1 && (
<Button
className="option-remove-button"
round
color="translucent"
size="smaller"
ariaLabel={lang('Delete')}
onClick={() => removeOption(index)}
iconName="close"
/>
)}
</div>
));
}
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]) && (
<p className="poll-error">{lang('lng_polls_choose_correct')}</p>
);
}
return (
<Modal isOpen={isOpen} onClose={onClear} header={renderHeader()} className="PollModal">
<InputText
ref={questionInputRef}
label={lang('AskAQuestion')}
value={question}
error={getQuestionError()}
onChange={handleQuestionChange}
onKeyPress={handleKeyPress}
/>
<div className="options-divider" />
<div className="options-list custom-scroll" ref={optionsListRef}>
<h3 className="options-header">{lang('PollOptions')}</h3>
{hasErrors && renderQuizNoOptionError()}
{isQuizMode ? (
<RadioGroup
name="correctOption"
options={renderRadioOptions()}
selected={String(correctOption)}
onChange={handleCorrectOptionChange}
/>
) : (
renderOptions()
)}
</div>
<div className="options-divider" />
<div className="quiz-mode">
<div className="dialog-checkbox-group">
{!shouldBeAnonymous && (
<Checkbox
label={lang('PollAnonymous')}
checked={isAnonymous}
onChange={handleIsAnonymousChange}
/>
)}
<Checkbox
label={lang('PollMultiple')}
checked={isMultipleAnswers}
disabled={isQuizMode}
onChange={handleMultipleAnswersChange}
/>
<Checkbox
label={lang('PollQuiz')}
checked={isQuizMode}
disabled={isMultipleAnswers || isQuiz !== undefined}
onChange={handleQuizModeChange}
/>
</div>
{isQuizMode && (
<>
<h3 className="options-header">{lang('lng_polls_solution_title')}</h3>
<TextArea
value={solution}
onChange={handleSolutionChange}
noReplaceNewlines
/>
<div className="note">{lang('CreatePoll.ExplanationInfo')}</div>
</>
)}
</div>
</Modal>
);
};
export default memo(PollModal);

View File

@ -62,6 +62,7 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
column-gap: 0.5rem; column-gap: 0.5rem;
min-height: 2.5rem;
} }
.question { .question {

View File

@ -6,7 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from '../../../../lib/teact/teact'; } from '../../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../../global'; import { getActions, getGlobal, getPromiseActions, withGlobal } from '../../../../global';
import type { import type {
ApiFormattedText, ApiFormattedText,
@ -34,6 +34,7 @@ import useLastCallback from '../../../../hooks/useLastCallback';
import AvatarList from '../../../common/AvatarList'; import AvatarList from '../../../common/AvatarList';
import CompactMapPreview from '../../../common/CompactMapPreview'; import CompactMapPreview from '../../../common/CompactMapPreview';
import Document from '../../../common/Document'; import Document from '../../../common/Document';
import Icon from '../../../common/icons/Icon';
import PeerColorWrapper from '../../../common/PeerColorWrapper'; import PeerColorWrapper from '../../../common/PeerColorWrapper';
import StickerView from '../../../common/StickerView'; import StickerView from '../../../common/StickerView';
import Button from '../../../ui/Button'; import Button from '../../../ui/Button';
@ -44,6 +45,7 @@ import Video from '../Video';
import PollOption from './PollOption'; import PollOption from './PollOption';
import styles from './Poll.module.scss'; import styles from './Poll.module.scss';
import optionStyles from './PollOption.module.scss';
type OwnProps = { type OwnProps = {
chatId: string; chatId: string;
@ -56,11 +58,16 @@ type OwnProps = {
observeIntersectionForPlaying?: ObserveFn; observeIntersectionForPlaying?: ObserveFn;
}; };
type StateProps = {
pollMaxAnswers: number;
};
const ATTACHED_MAP_WIDTH = 350; const ATTACHED_MAP_WIDTH = 350;
const ATTACHED_MAP_HEIGHT = 200; const ATTACHED_MAP_HEIGHT = 200;
const ATTACHED_MAP_ZOOM = 15; const ATTACHED_MAP_ZOOM = 15;
const STICKER_PREVIEW_SIZE = 96; const STICKER_PREVIEW_SIZE = 96;
const VOTE_TIMEOUT = 5000; const VOTE_TIMEOUT = 5000;
const MAX_OPTION_LENGTH = 100;
const Poll = ({ const Poll = ({
chatId, chatId,
@ -71,7 +78,8 @@ const Poll = ({
isInScheduled, isInScheduled,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
}: OwnProps) => { pollMaxAnswers,
}: OwnProps & StateProps) => {
const { const {
openMapModal, openMapModal,
openMediaViewer, openMediaViewer,
@ -80,6 +88,7 @@ const Poll = ({
sendPollVote, sendPollVote,
loadMessage, loadMessage,
} = getActions(); } = getActions();
const { appendPollAnswer } = getPromiseActions();
const lang = useLang(); const lang = useLang();
const serverTime = getServerTime(); const serverTime = getServerTime();
@ -88,6 +97,8 @@ const Poll = ({
const [selectedOptions, setSelectedOptions] = useState<string[]>([]); const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const [isExplanationOpen, setIsExplanationOpen] = useState(false); const [isExplanationOpen, setIsExplanationOpen] = useState(false);
const [isSendingVote, setIsSendingVote] = useState(false); const [isSendingVote, setIsSendingVote] = useState(false);
const [isAppendingAnswer, setIsAppendingAnswer] = useState(false);
const [newAnswerText, setNewAnswerText] = useState('');
const [isViewingAuthorResults, setIsViewingAuthorResults] = useState(false); const [isViewingAuthorResults, setIsViewingAuthorResults] = useState(false);
const [answerOrder] = useState<string[]>(() => ( const [answerOrder] = useState<string[]>(() => (
buildAnswerOrder(answers, summary.shouldShuffleAnswers) buildAnswerOrder(answers, summary.shouldShuffleAnswers)
@ -118,6 +129,11 @@ const Poll = ({
(!canVote && !areResultsHiddenForCurrentUser) || isViewingAuthorResults (!canVote && !areResultsHiddenForCurrentUser) || isViewingAuthorResults
); );
const canShowResultsPanel = hasChosenAnswer && summary.isPublic && hasResultData && !areResultsHiddenForCurrentUser; const canShowResultsPanel = hasChosenAnswer && summary.isPublic && hasResultData && !areResultsHiddenForCurrentUser;
const canShowExplanation = hasExplanation && hasChosenAnswer;
const canAppendAnswer = Boolean(
summary.canAddAnswers && !summary.isClosed && !isInScheduled && answers.length < pollMaxAnswers,
);
const trimmedNewAnswerText = newAnswerText.trim().substring(0, MAX_OPTION_LENGTH);
useEffect(() => { useEffect(() => {
if (!canVote) { if (!canVote) {
@ -162,10 +178,19 @@ const Poll = ({
}, [canToggleAuthorResults]); }, [canToggleAuthorResults]);
useEffect(() => { useEffect(() => {
if (!hasExplanation) { if (!canShowExplanation) {
setIsExplanationOpen(false); setIsExplanationOpen(false);
} }
}, [hasExplanation]); }, [canShowExplanation]);
useEffect(() => {
if (canAppendAnswer) {
return;
}
setNewAnswerText('');
setIsAppendingAnswer(false);
}, [canAppendAnswer]);
const answersByOption = useMemo(() => buildCollectionByKey(answers, 'option'), [answers]); const answersByOption = useMemo(() => buildCollectionByKey(answers, 'option'), [answers]);
@ -261,6 +286,37 @@ const Poll = ({
submitVote(selectedOptions); submitVote(selectedOptions);
}); });
const handleNewAnswerChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewAnswerText(e.currentTarget.value);
});
const handleAppendAnswer = useLastCallback(async () => {
if (!canAppendAnswer || !trimmedNewAnswerText || isAppendingAnswer) {
return;
}
setIsAppendingAnswer(true);
try {
await appendPollAnswer({
chatId,
messageId,
text: trimmedNewAnswerText,
});
setNewAnswerText('');
} finally {
setIsAppendingAnswer(false);
}
});
const handleNewAnswerKeyDown = useLastCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') {
return;
}
e.preventDefault();
handleAppendAnswer();
});
const handleOpenPreview = useLastCallback((previewIndex: number) => { const handleOpenPreview = useLastCallback((previewIndex: number) => {
if (!standaloneMedia.length) { if (!standaloneMedia.length) {
return; return;
@ -372,6 +428,23 @@ const Poll = ({
</div> </div>
); );
if (trimmedNewAnswerText) {
return (
<Button
className={styles.footerButton}
disabled={isAppendingAnswer || !canAppendAnswer}
noForcedUpperCase
isText
inline
size="smaller"
color="adaptive"
onClick={handleAppendAnswer}
>
{renderFooterBody(lang('Save'))}
</Button>
);
}
if (canVote && isMultipleChoice && selectedOptions.length) { if (canVote && isMultipleChoice && selectedOptions.length) {
return ( return (
<Button <Button
@ -479,12 +552,16 @@ const Poll = ({
canShowResultsPanel, canShowResultsPanel,
canToggleAuthorResults, canToggleAuthorResults,
canVote, canVote,
canAppendAnswer,
handleAppendAnswer,
isAppendingAnswer,
isMultipleChoice, isMultipleChoice,
isSendingVote, isSendingVote,
isViewingAuthorResults, isViewingAuthorResults,
lang, lang,
selectedOptions.length, selectedOptions.length,
summary.isQuiz, summary.isQuiz,
trimmedNewAnswerText,
footerSubtext, footerSubtext,
totalVoters, totalVoters,
]); ]);
@ -493,7 +570,7 @@ const Poll = ({
<> <>
{attachedMediaEl} {attachedMediaEl}
<div className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}> <div className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}>
{isExplanationOpen && hasExplanation && ( {isExplanationOpen && canShowExplanation && (
<PeerColorWrapper className={styles.explanation} shouldReset> <PeerColorWrapper className={styles.explanation} shouldReset>
<div className={styles.explanationHeader}> <div className={styles.explanationHeader}>
<span className={styles.explanationTitle}> <span className={styles.explanationTitle}>
@ -531,7 +608,7 @@ const Poll = ({
<div className={styles.question} dir="auto"> <div className={styles.question} dir="auto">
{questionText} {questionText}
</div> </div>
{hasExplanation && !isExplanationOpen && ( {canShowExplanation && !isExplanationOpen && (
<div className={styles.explanationToggleButton}> <div className={styles.explanationToggleButton}>
<Button <Button
round round
@ -595,6 +672,31 @@ const Poll = ({
/> />
); );
})} })}
{canAppendAnswer && (
<div
className={buildClassName(
optionStyles.root,
optionStyles.addAnswer,
hasOptionMedia && optionStyles.hasMediaColumn,
)}
>
<div className={optionStyles.selector}>
<Icon name="add" className={optionStyles.addAnswerIcon} />
</div>
<div className={buildClassName(optionStyles.answer, optionStyles.addAnswerContent)}>
<input
className={optionStyles.addAnswerInput}
value={newAnswerText}
placeholder={lang('CreatePollAddOption')}
maxLength={MAX_OPTION_LENGTH}
dir="auto"
disabled={isAppendingAnswer}
onChange={handleNewAnswerChange}
onKeyDown={handleNewAnswerKeyDown}
/>
</div>
</div>
)}
</div> </div>
{footerContent && ( {footerContent && (
<div className={styles.footer}> <div className={styles.footer}>
@ -761,4 +863,8 @@ function onPreviewClick(onOpenPreview: (previewIndex: number) => void) {
}; };
} }
export default memo(Poll); export default memo(withGlobal<OwnProps>((global): Complete<StateProps> => {
return {
pollMaxAnswers: global.appConfig.pollMaxAnswers,
};
})(Poll));

View File

@ -29,6 +29,10 @@
} }
} }
.clickable {
cursor: var(--custom-cursor, pointer);
}
.hasMediaColumn { .hasMediaColumn {
--poll-option-media-width: 3rem; --poll-option-media-width: 3rem;
} }
@ -48,6 +52,31 @@
align-items: center; align-items: center;
} }
.addAnswerIcon {
font-size: 1.5rem;
color: var(--color-text-secondary);
}
.addAnswerInput {
width: 100%;
min-width: 0;
padding: 0;
border: 0;
font: inherit;
line-height: inherit;
color: inherit;
background: transparent;
outline: 0;
&:focus-visible {
border-radius: 0.25rem;
outline: 0.125rem solid var(--accent-color);
outline-offset: 0.125rem;
}
}
.spinner { .spinner {
--spinner-size: 1.25rem; --spinner-size: 1.25rem;
} }
@ -79,6 +108,15 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.addAnswer {
cursor: text;
}
.addAnswerContent {
grid-column: 2 / 4;
justify-self: stretch;
}
.sideMeta { .sideMeta {
display: flex; display: flex;
grid-column: 3; grid-column: 3;

View File

@ -314,8 +314,9 @@ const PollOption = ({
hasResults && !hasMaskedResults && styles.hasResults, hasResults && !hasMaskedResults && styles.hasResults,
shouldReserveMediaEndColumn && styles.hasMediaColumn, shouldReserveMediaEndColumn && styles.hasMediaColumn,
hasResults && !hasMaskedResults && isQuiz && !result?.isCorrect && styles.incorrect, hasResults && !hasMaskedResults && isQuiz && !result?.isCorrect && styles.incorrect,
!hasResults && !isInScheduled && styles.clickable,
)} )}
onClick={handleClick} onClick={!isInScheduled ? handleClick : undefined}
> >
<Transition <Transition
name="fade" name="fade"

View File

@ -3,9 +3,12 @@ import { withGlobal } from '../../global';
import type { TabState } from '../../global/types'; import type { TabState } from '../../global/types';
import { selectTabState } from '../../global/selectors'; import { selectCanAnimateInterface, selectTabState } from '../../global/selectors';
import { pick } from '../../util/iteratees'; import { pick } from '../../util/iteratees';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import useShowTransition from '../../hooks/useShowTransition';
import VerificationMonetizationModal from '../common/VerificationMonetizationModal.async'; import VerificationMonetizationModal from '../common/VerificationMonetizationModal.async';
import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal.async'; import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal.async';
import AiMessageEditorModal from '../middle/composer/AiMessageEditorModal/AiMessageEditorModal.async'; import AiMessageEditorModal from '../middle/composer/AiMessageEditorModal/AiMessageEditorModal.async';
@ -55,6 +58,7 @@ import MapModal from './map/MapModal.async';
import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async'; import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async';
import PaidReactionModal from './paidReaction/PaidReactionModal.async'; import PaidReactionModal from './paidReaction/PaidReactionModal.async';
import PasskeyModal from './passkey/PasskeyModal.async'; import PasskeyModal from './passkey/PasskeyModal.async';
import PollModal from './poll/PollModal.async';
import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async'; import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async';
import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async'; import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async';
import ProfileRatingModal from './profileRating/ProfileRatingModal.async'; import ProfileRatingModal from './profileRating/ProfileRatingModal.async';
@ -97,6 +101,7 @@ type ModalKey = keyof Pick<TabState,
'starsPayment' | 'starsPayment' |
'starsTransactionModal' | 'starsTransactionModal' |
'paidReactionModal' | 'paidReactionModal' |
'pollModal' |
'suggestMessageModal' | 'suggestMessageModal' |
'suggestedPostApprovalModal' | 'suggestedPostApprovalModal' |
'webApps' | 'webApps' |
@ -154,20 +159,68 @@ type ModalKey = keyof Pick<TabState,
'editRankModal' | 'editRankModal' |
'rankModal' 'rankModal'
>; >;
type WrappedModalKey = 'pollModal';
type LegacyModalKey = Exclude<ModalKey, WrappedModalKey>;
type StateProps = { type ModalStateProps = {
[K in ModalKey]?: TabState[K]; [K in ModalKey]?: TabState[K];
}; };
type ModalRegistry = { type StateProps = ModalStateProps & {
[K in ModalKey]: FC<{ shouldAnimateInterface: boolean;
};
type LegacyModalRegistry = {
[K in LegacyModalKey]: FC<{
modal: TabState[K]; modal: TabState[K];
}>; }>;
}; };
type WrappedModalRegistry = {
[K in WrappedModalKey]: FC<{
modal: NonNullable<TabState[K]>;
isOpen: boolean;
}>;
};
type Entries<T> = { type Entries<T> = {
[K in keyof T]: [K, T[K]]; [K in keyof T]: [K, T[K]];
}[keyof T][]; }[keyof T][];
const MODALS: ModalRegistry = { const POLL_MODAL_CLOSE_DURATION = 200;
const WRAPPED_MODAL_CLOSE_DURATIONS: Record<WrappedModalKey, number> = {
pollModal: POLL_MODAL_CLOSE_DURATION,
};
type WrappedModalBoundaryProps<T> = {
modal: T;
ModalComponent: FC<{
modal: NonNullable<T>;
isOpen: boolean;
}>;
closeDuration: number;
shouldAnimateInterface: boolean;
};
const WrappedModalBoundary = <T,>({
modal,
ModalComponent,
closeDuration,
shouldAnimateInterface,
}: WrappedModalBoundaryProps<T>) => {
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal, true);
const { shouldRender } = useShowTransition({
isOpen,
withShouldRender: true,
closeDuration,
noCloseTransition: !shouldAnimateInterface,
});
if (!shouldRender || !renderingModal) {
return undefined;
}
return <ModalComponent modal={renderingModal} isOpen={isOpen} />;
};
const LEGACY_MODALS: LegacyModalRegistry = {
aiMessageEditorModal: AiMessageEditorModal, aiMessageEditorModal: AiMessageEditorModal,
giftCodeModal: GiftCodeModal, giftCodeModal: GiftCodeModal,
boostModal: BoostModal, boostModal: BoostModal,
@ -241,18 +294,41 @@ const MODALS: ModalRegistry = {
editRankModal: EditRankModal, editRankModal: EditRankModal,
rankModal: RankModal, rankModal: RankModal,
}; };
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const WRAPPED_MODALS: WrappedModalRegistry = {
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>; pollModal: PollModal,
};
const LEGACY_MODAL_KEYS = Object.keys(LEGACY_MODALS) as LegacyModalKey[];
const WRAPPED_MODAL_KEYS = Object.keys(WRAPPED_MODALS) as WrappedModalKey[];
const MODAL_KEYS = [...LEGACY_MODAL_KEYS, ...WRAPPED_MODAL_KEYS] as ModalKey[];
const LEGACY_MODAL_ENTRIES = Object.entries(LEGACY_MODALS) as Entries<LegacyModalRegistry>;
const WRAPPED_MODAL_ENTRIES = Object.entries(WRAPPED_MODALS) as Entries<WrappedModalRegistry>;
const ModalContainer = (modalProps: StateProps) => { const ModalContainer = (modalProps: StateProps) => {
return MODAL_ENTRIES.map(([key, ModalComponent]) => ( const { shouldAnimateInterface } = modalProps;
// @ts-ignore -- TS does not preserve tuple types in `map` callbacks
<ModalComponent key={key} modal={modalProps[key]} /> return [
)); ...LEGACY_MODAL_ENTRIES.map(([key, ModalComponent]) => (
// @ts-ignore -- TS does not preserve tuple types in `map` callbacks
<ModalComponent key={key} modal={modalProps[key]} />
)),
...WRAPPED_MODAL_ENTRIES.map(([key, ModalComponent]) => (
// @ts-ignore -- TS does not preserve tuple types in `map` callbacks
<WrappedModalBoundary
key={key}
modal={modalProps[key]}
ModalComponent={ModalComponent}
closeDuration={WRAPPED_MODAL_CLOSE_DURATIONS[key]}
shouldAnimateInterface={shouldAnimateInterface}
/>
)),
];
}; };
export default memo(withGlobal( export default memo(withGlobal(
(global): Complete<StateProps> => ( (global): Complete<StateProps> => ({
pick(selectTabState(global), MODAL_KEYS) as Complete<StateProps> ...(pick(selectTabState(global), MODAL_KEYS) as Complete<ModalStateProps>),
), shouldAnimateInterface: selectCanAnimateInterface(global),
}),
)(ModalContainer)); )(ModalContainer));

View File

@ -0,0 +1,141 @@
.input,
.optionInput,
.textArea {
margin: 0.5rem;
}
.optionList {
display: flex;
flex-direction: column;
}
.optionRowFrame,
.durationMenuWrapper {
position: relative;
}
.optionRow {
display: flex;
align-items: center;
border-radius: var(--border-radius-default);
}
.optionRowDragging {
--custom-cursor: grabbing;
will-change: transform;
cursor: var(--custom-cursor);
z-index: var(--z-modal-menu);
contain: content;
opacity: 0.95;
background: var(--color-background);
box-shadow: 0 0.25rem 1rem var(--color-default-shadow);
input {
cursor: var(--custom-cursor);
}
}
.optionRowAdd {
color: var(--color-text-secondary);
}
.optionLeadingIcon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 1.5rem;
color: var(--color-text-secondary);
opacity: 0.72;
}
.optionLeadingIconAdd {
opacity: 0.5;
}
.optionLeadingIconGlyph {
font-size: 1.5rem;
}
.optionDragHandle {
cursor: grab;
border-radius: 0.25rem;
&:active {
cursor: grabbing;
}
&:focus-visible {
outline: 0.125rem solid var(--color-primary);
outline-offset: 0.125rem;
}
@media (pointer: coarse) {
touch-action: none;
}
}
.optionSelector {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
margin-inline-start: 0.5rem;
}
.optionRadioInvalid {
--ui-border-color: var(--color-error);
--ui-accent-color: var(--color-error);
}
.optionInput {
flex: 1 1 auto;
min-width: 0;
&:global(.input-group) {
flex: 1 1 auto;
min-width: 0;
}
}
.optionTextInput {
:global(.form-control) {
min-width: 0;
}
}
.optionInputAdd {
:global(.form-control)::placeholder {
color: var(--color-text-secondary);
opacity: 1;
}
}
.optionRemove {
flex: 0 0 auto;
}
.rowValue {
font-size: 0.9375rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
text-align: end;
}
.errorDescription {
padding: 0.75rem 0 0;
color: var(--color-error);
}
.durationMenu :global(.backdrop) {
position: absolute;
inset: 0;
}

View File

@ -0,0 +1,949 @@
import type { TeactNode } from '../../../lib/teact/teact';
import {
memo,
useEffect,
useMemo,
useRef,
useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiNewPoll } from '../../../api/types';
import type { TabState } from '../../../global/types';
import type { MessageList } from '../../../types';
import type { IconName } from '../../../types/icons';
import { MAIN_THREAD_ID } from '../../../api/types';
import { requestMeasure } from '../../../lib/fasterdom/fasterdom';
import { isChatChannel } from '../../../global/helpers';
import { getChatNotifySettings } from '../../../global/helpers/notifications';
import { getPeerTitle } from '../../../global/helpers/peers';
import {
selectChat,
selectNotifyDefaults,
selectNotifyException,
selectPeerPaidMessagesStars,
selectTabState,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatDateTimeToString, formatShortDuration } from '../../../util/dates/oldDateFormat';
import { DAY, HOUR } from '../../../util/dates/units';
import { generateUniqueNumberId } from '../../../util/generateUniqueId';
import { getServerTime } from '../../../util/serverTime';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useReorderableList from '../../../hooks/useReorderableList';
import useSchedule from '../../../hooks/useSchedule';
import usePaidMessageConfirmation from '../../middle/composer/hooks/usePaidMessageConfirmation';
import CalendarModal from '../../common/CalendarModal.async';
import Icon from '../../common/icons/Icon';
import PaymentMessageConfirmDialog from '../../common/PaymentMessageConfirmDialog';
import CustomSendMenu from '../../middle/composer/CustomSendMenu.async';
import Button from '../../ui/Button';
import InputText from '../../ui/InputText';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import TextArea from '../../ui/TextArea';
import Control, {
ControlAfter,
ControlDescription,
ControlIcon,
ControlLabel,
} from '@gili/layout/Control';
import Interactive from '@gili/layout/Interactive';
import Island, {
IslandDescription,
IslandTitle,
} from '@gili/layout/Island';
import Modal, {
ModalCloseButton,
ModalHeader,
ModalHeaderAction,
ModalTitle,
} from '@gili/modal/Modal';
import Checkbox from '@gili/primitives/Checkbox';
import Radio from '@gili/primitives/Radio';
import Switch from '@gili/primitives/Switch';
import styles from './PollModal.module.scss';
const MAX_OPTION_LENGTH = 100;
const MAX_QUESTION_LENGTH = 255;
const MAX_SOLUTION_LENGTH = 200;
const CLOSE_PERIOD_OPTIONS = [
HOUR,
3 * HOUR,
8 * HOUR,
DAY,
3 * DAY,
];
const ICON_COLORS = {
anonymous: '#0a84ff',
multiple: '#ffb300',
quiz: '#34c759',
addAnswers: '#2faeff',
revote: '#6a5cff',
shuffle: '#b75bff',
duration: '#ff5e3a',
results: '#3a8cff',
} as const;
export type OwnProps = {
modal: NonNullable<TabState['pollModal']>;
isOpen: boolean;
};
type StateProps = {
chat?: ApiChat;
isChannel?: boolean;
pollMaxAnswers: number;
pollClosePeriodMax: number;
paidMessagesStars?: number;
isPaymentMessageConfirmDialogOpen: boolean;
starsBalance: number;
isStarsBalanceModalOpen: boolean;
isSilentPosting?: boolean;
};
type SettingRowProps = {
iconName?: IconName;
iconBackgroundColor?: string;
label: TeactNode;
description?: TeactNode;
checked: boolean;
disabled?: boolean;
locked?: boolean;
onChange: (checked: boolean) => void;
};
type ValueRowProps = {
iconName?: IconName;
iconBackgroundColor?: string;
label: TeactNode;
value: TeactNode;
onClick: (e: React.MouseEvent<HTMLElement>) => void;
};
type PollOption = {
id: string;
text: string;
};
const PollModal = ({
modal,
isOpen,
chat,
isChannel,
pollMaxAnswers,
pollClosePeriodMax,
paidMessagesStars,
isPaymentMessageConfirmDialogOpen,
starsBalance,
isStarsBalanceModalOpen,
isSilentPosting,
}: OwnProps & StateProps) => {
const {
closePollModal,
sendMessage,
} = getActions();
const lang = useLang();
const questionInputRef = useRef<HTMLInputElement>();
const mainButtonRef = useRef<HTMLButtonElement>();
const optionListRef = useRef<HTMLDivElement>();
const durationMenuRef = useRef<HTMLDivElement>();
const [question, setQuestion] = useState('');
const [description, setDescription] = useState('');
const [options, setOptions] = useState<PollOption[]>(() => [createPollOption()]);
const [isPublic, setIsPublic] = useState(true);
const [isMultipleAnswers, setIsMultipleAnswers] = useState(true);
const [isQuizMode, setIsQuizMode] = useState(Boolean(modal.isQuiz));
const [correctAnswerIds, setCorrectAnswerIds] = useState<string[]>([]);
const [solution, setSolution] = useState('');
const [canAddAnswers, setCanAddAnswers] = useState(true);
const [canRevote, setCanRevote] = useState(true);
const [shouldShuffleAnswers, setShouldShuffleAnswers] = useState(false);
const [closePeriod, setClosePeriod] = useState<number | undefined>();
const [closeDate, setCloseDate] = useState<number | undefined>();
const [durationAnchorAt, setDurationAnchorAt] = useState(() => getServerTime());
const [shouldHideResultsUntilClose, setShouldHideResultsUntilClose] = useState(false);
const [hasSubmitted, setHasSubmitted] = useState(false);
const [isCloseDatePickerOpen, setIsCloseDatePickerOpen] = useState(false);
const [requestCalendar, calendar] = useSchedule();
const {
isContextMenuOpen: isCustomSendMenuOpen,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(mainButtonRef, !isOpen || modal.messageListType === 'scheduled');
const {
isContextMenuOpen: isDurationMenuOpen,
handleContextMenu: handleDurationMenuOpen,
handleContextMenuClose: handleDurationMenuClose,
handleContextMenuHide: handleDurationMenuHide,
} = useContextMenuHandlers(durationMenuRef, !isOpen);
const {
closeConfirmDialog,
dialogHandler,
shouldAutoApprove,
setAutoApprove,
handleWithConfirmation,
} = usePaidMessageConfirmation(
paidMessagesStars || 0,
isStarsBalanceModalOpen,
starsBalance,
true,
);
useEffect(() => {
if (!isOpen) {
return;
}
questionInputRef.current?.focus();
}, [isOpen]);
useEffect(() => {
if (isChannel) {
setIsPublic(false);
setCanAddAnswers(false);
}
}, [isChannel]);
useEffect(() => {
if (isQuizMode || !isPublic) {
setCanAddAnswers(false);
}
}, [isPublic, isQuizMode]);
useEffect(() => {
if (closePeriod || closeDate) {
return;
}
setShouldHideResultsUntilClose(false);
}, [closeDate, closePeriod]);
useEffect(() => {
if (!isMultipleAnswers && correctAnswerIds.length > 1) {
setCorrectAnswerIds(correctAnswerIds.slice(0, 1));
}
}, [correctAnswerIds, isMultipleAnswers]);
const filledOptions = useMemo(() => {
return options.map((option) => ({
id: option.id,
text: option.text.trim().substring(0, MAX_OPTION_LENGTH),
})).filter(({ text }) => Boolean(text));
}, [options]);
const correctAnswerPositions = useMemo(() => {
return correctAnswerIds.reduce<number[]>((result, id) => {
const answerIndex = filledOptions.findIndex((option) => option.id === id);
if (answerIndex >= 0) {
result.push(answerIndex);
}
return result;
}, []);
}, [correctAnswerIds, filledOptions]);
const reorderableOptionIds = useMemo(() => {
return filledOptions.map(({ id }) => id);
}, [filledOptions]);
const trimmedQuestion = useMemo(() => question.trim().substring(0, MAX_QUESTION_LENGTH), [question]);
const isInScheduledList = modal.messageListType === 'scheduled';
const canSchedule = Boolean(!paidMessagesStars && !chat?.isMonoforum);
const isCorrectAnswerInvalid = hasSubmitted && isQuizMode && !correctAnswerPositions.length;
const isAddAnswersDisabled = isQuizMode || !isPublic;
const remainingOptionsCount = Math.max(pollMaxAnswers - filledOptions.length, 0);
const isSendDisabled = !trimmedQuestion
|| filledOptions.length < 1
|| (isQuizMode && !correctAnswerPositions.length);
const hasLimitedDuration = closePeriod !== undefined || closeDate !== undefined;
const closeDateLabel = closeDate !== undefined
? formatDateTimeToString(closeDate * 1000, lang.code, true)
: closePeriod !== undefined
? formatShortDuration(lang, closePeriod)
: lang('PollSelectCloseDate');
const maxCloseDateAt = (durationAnchorAt + pollClosePeriodMax) * 1000;
const closeDatePickerSelectedAt = closeDate !== undefined
? closeDate * 1000
: (durationAnchorAt + (closePeriod || DAY)) * 1000;
const messageList: MessageList = {
chatId: modal.chatId,
threadId: modal.threadId || MAIN_THREAD_ID,
type: modal.messageListType === 'scheduled' ? 'scheduled' : 'thread',
};
const handleClose = useLastCallback(() => {
closePollModal();
});
const handleReorderOptions = useLastCallback((optionIds: string[]) => {
setOptions((currentOptions) => {
const optionsById = new Map(currentOptions.map((option) => [option.id, option]));
const nextOptions = optionIds.reduce<PollOption[]>((result, id) => {
const option = optionsById.get(id);
if (option) {
result.push(option);
}
return result;
}, []);
return normalizeOptions(nextOptions, pollMaxAnswers);
});
});
const {
draggedId: draggedOptionId,
getRowProps: getReorderableRowProps,
getDragElementProps: getReorderableDragElementProps,
getHandleProps: getReorderableHandleProps,
getPlaceholderStyle: getReorderablePlaceholderStyle,
getDragStyle: getReorderableDragStyle,
} = useReorderableList({
itemIds: reorderableOptionIds,
withAutoscroll: true,
onReorder: handleReorderOptions,
});
const updateOption = useLastCallback((id: string, value: string) => {
const nextOptions = options.map((option) => (
option.id === id ? { ...option, text: value } : option
));
setOptions(normalizeOptions(nextOptions, pollMaxAnswers));
});
const handleRemoveOption = useLastCallback((id: string) => {
const nextOptions = normalizeOptions(options.filter((option) => option.id !== id), pollMaxAnswers);
setOptions(nextOptions);
setCorrectAnswerIds(correctAnswerIds.filter((correctAnswerId) => correctAnswerId !== id));
});
const handleToggleCorrectAnswer = useLastCallback((id: string) => {
if (!isMultipleAnswers) {
setCorrectAnswerIds([id]);
return;
}
setCorrectAnswerIds((prevCorrectAnswers) => (
prevCorrectAnswers.includes(id)
? prevCorrectAnswers.filter((currentId) => currentId !== id)
: [...prevCorrectAnswers, id]
));
});
const handleOptionKeyDown = useLastCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
if (!optionListRef.current) {
return;
}
const inputs = optionListRef.current.querySelectorAll<HTMLInputElement>(`.${styles.optionTextInput} input`);
const lastInput = inputs[inputs.length - 1];
if (!lastInput) {
return;
}
requestMeasure(() => {
lastInput.focus();
});
});
const handleQuestionChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setQuestion(e.currentTarget.value);
});
const handleDescriptionChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setDescription(e.currentTarget.value);
});
const handleSolutionChange = useLastCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setSolution(e.currentTarget.value);
});
const handleQuizModeChange = useLastCallback((checked: boolean) => {
setIsQuizMode(checked);
});
const handleMultipleAnswersChange = useLastCallback((checked: boolean) => {
setIsMultipleAnswers(checked);
});
const handleLimitedDurationChange = useLastCallback((checked: boolean) => {
if (!checked) {
setClosePeriod(undefined);
setCloseDate(undefined);
setShouldHideResultsUntilClose(false);
return;
}
const nowAt = Math.floor(Date.now() / 1000);
setDurationAnchorAt(nowAt);
setClosePeriod(DAY);
setCloseDate(undefined);
});
const handleCloseDateSave = useLastCallback((date: Date) => {
setClosePeriod(undefined);
setCloseDate(Math.round(date.getTime() / 1000));
setIsCloseDatePickerOpen(false);
});
const handleCloseCloseDatePicker = useLastCallback(() => {
setIsCloseDatePickerOpen(false);
});
const handleOpenCloseDatePicker = useLastCallback(() => {
setDurationAnchorAt(Math.floor(Date.now() / 1000));
setIsCloseDatePickerOpen(true);
});
const handleSelectClosePeriod = useLastCallback((period: number) => {
setClosePeriod(period);
setCloseDate(undefined);
});
const buildPoll = useLastCallback((): ApiNewPoll | undefined => {
const normalizedOptions = normalizeOptions(
options.map((option) => ({
...option,
text: option.text.trim().substring(0, MAX_OPTION_LENGTH),
})),
pollMaxAnswers,
);
setQuestion(trimmedQuestion);
setOptions(normalizedOptions);
setHasSubmitted(true);
if (!trimmedQuestion || filledOptions.length < 1) {
return undefined;
}
if (isQuizMode && !correctAnswerPositions.length) {
return undefined;
}
const answers = filledOptions.map(({ text }, index) => ({
text: { text },
option: String(index),
}));
const payload: ApiNewPoll = {
summary: {
id: generateUniqueNumberId().toString(),
hash: '0',
question: {
text: trimmedQuestion,
},
answers,
isPublic: !isChannel && isPublic ? true : undefined,
isMultipleChoice: isMultipleAnswers ? true : undefined,
isQuiz: isQuizMode ? true : undefined,
canAddAnswers: !isChannel && isPublic && canAddAnswers ? true : undefined,
isRevoteDisabled: !canRevote ? true : undefined,
shouldShuffleAnswers: shouldShuffleAnswers ? true : undefined,
shouldHideResultsUntilClose: shouldHideResultsUntilClose ? true : undefined,
closePeriod,
closeDate,
isCreator: true,
},
correctAnswers: isQuizMode ? correctAnswerPositions : undefined,
solution: isQuizMode ? solution.trim().substring(0, MAX_SOLUTION_LENGTH) : undefined,
};
return payload;
});
const submitPoll = useLastCallback((
poll: ApiNewPoll,
isSilent?: boolean,
scheduledAt?: number,
scheduleRepeatPeriod?: number,
) => {
sendMessage({
messageList,
text: description,
poll,
isSilent: scheduledAt ? undefined : (isSilent || isSilentPosting),
scheduledAt,
scheduleRepeatPeriod,
});
closePollModal();
});
const handleSendNow = useLastCallback((isSilent?: boolean) => {
const poll = buildPoll();
if (!poll) {
return;
}
handleWithConfirmation(submitPoll, poll, isSilent);
});
const handleSendScheduled = useLastCallback((scheduledAt: number, scheduleRepeatPeriod?: number) => {
const poll = buildPoll();
if (!poll) {
return;
}
handleWithConfirmation(submitPoll, poll, undefined, scheduledAt, scheduleRepeatPeriod);
});
const handlePrimarySend = useLastCallback(() => {
if (isInScheduledList) {
requestCalendar(handleSendScheduled);
return;
}
handleSendNow();
});
const handleSilentSend = useLastCallback(() => {
handleSendNow(true);
});
const handleScheduleSend = useLastCallback(() => {
requestCalendar(handleSendScheduled);
});
const renderHeader = useMemo(() => (
<ModalHeader>
<ModalCloseButton />
<ModalTitle>{lang('NewPoll')}</ModalTitle>
<ModalHeaderAction>
<Button
ref={mainButtonRef}
color="primary"
pill
disabled={isSendDisabled}
noForcedUpperCase
size="smaller"
onClick={handlePrimarySend}
onContextMenu={!isInScheduledList ? handleContextMenu : undefined}
>
{lang('Send')}
</Button>
{!isInScheduledList && (
<CustomSendMenu
isOpen={isCustomSendMenuOpen}
canSchedule={canSchedule}
onSendSilent={handleSilentSend}
onSendSchedule={handleScheduleSend}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
/>
)}
</ModalHeaderAction>
</ModalHeader>
), [canSchedule, handleContextMenu, handleContextMenuClose, handleContextMenuHide,
isCustomSendMenuOpen, isInScheduledList, isSendDisabled, lang]);
return (
<>
<Modal
isOpen={isOpen}
onClose={handleClose}
header={renderHeader}
ariaLabel={lang('NewPoll')}
width="slim"
>
<IslandTitle>{lang('PollModalQuestionTitle')}</IslandTitle>
<Island>
<InputText
ref={questionInputRef}
className={styles.input}
label={lang('AskAQuestion')}
value={question}
maxLength={MAX_QUESTION_LENGTH}
error={hasSubmitted && !trimmedQuestion ? lang('PollsChooseQuestion') : undefined}
onChange={handleQuestionChange}
/>
<InputText
className={styles.input}
label={lang('DescriptionOptionalPlaceholder')}
value={description}
onChange={handleDescriptionChange}
/>
</Island>
<IslandTitle>{lang('PollModalOptionsTitle')}</IslandTitle>
<Island ref={optionListRef} className={styles.optionList} teactFastList>
{options.map((option, index) => {
const isFilledOption = Boolean(option.text.trim());
const isAddOptionRow = !isFilledOption && index === options.length - 1;
const isCorrectAnswerChecked = correctAnswerIds.includes(option.id);
const shouldShowRemoveButton = options.length > 1 && !isAddOptionRow;
const shouldShowOptionError = hasSubmitted && !filledOptions.length && index === 0;
const rowProps = isFilledOption ? getReorderableRowProps(option.id) : undefined;
const handleProps = isFilledOption ? getReorderableHandleProps(option.id) : undefined;
const dragElementProps = isFilledOption ? getReorderableDragElementProps(option.id) : undefined;
const placeholderStyle = isFilledOption ? getReorderablePlaceholderStyle(option.id) : undefined;
const dragStyle = isFilledOption ? getReorderableDragStyle(option.id) : undefined;
return (
<div
key={option.id}
ref={rowProps?.ref}
className={styles.optionRowFrame}
style={placeholderStyle}
>
<div
ref={dragElementProps?.ref}
style={dragStyle}
className={buildClassName(
styles.optionRow,
isAddOptionRow && styles.optionRowAdd,
draggedOptionId === option.id && styles.optionRowDragging,
)}
>
<div
className={buildClassName(
styles.optionLeadingIcon,
isAddOptionRow && styles.optionLeadingIconAdd,
isFilledOption && styles.optionDragHandle,
)}
role={handleProps?.role}
tabIndex={handleProps?.tabIndex}
aria-label={isFilledOption ? lang('DragToSortAria') : undefined}
onMouseDown={handleProps?.onMouseDown}
onTouchStart={handleProps?.onTouchStart}
onKeyDown={handleProps?.onKeyDown}
ref={handleProps?.ref}
>
<Icon
name={isAddOptionRow ? 'add' : 'sort'}
className={styles.optionLeadingIconGlyph}
/>
</div>
{isQuizMode && (
<div className={styles.optionSelector}>
{isMultipleAnswers ? (
<Checkbox
checked={isCorrectAnswerChecked}
disabled={isAddOptionRow}
isInvalid={isCorrectAnswerInvalid && !isCorrectAnswerChecked && !isAddOptionRow}
onChange={() => handleToggleCorrectAnswer(option.id)}
/>
) : (
<Radio
value={option.id}
checked={isCorrectAnswerChecked}
disabled={isAddOptionRow}
className={isCorrectAnswerInvalid && !isCorrectAnswerChecked && !isAddOptionRow
? styles.optionRadioInvalid
: undefined}
onChange={() => handleToggleCorrectAnswer(option.id)}
/>
)}
</div>
)}
<InputText
className={buildClassName(
styles.optionInput,
styles.optionTextInput,
isAddOptionRow && styles.optionInputAdd,
)}
placeholder={isAddOptionRow ? lang('CreatePollAddOption') : lang('OptionHint')}
value={option.text}
maxLength={MAX_OPTION_LENGTH}
error={shouldShowOptionError ? lang('PollsChooseAnswers') : undefined}
onChange={(e) => updateOption(option.id, e.currentTarget.value)}
onKeyDown={handleOptionKeyDown}
/>
{shouldShowRemoveButton && (
<Button
round
size="tiny"
color="translucent"
className={styles.optionRemove}
ariaLabel={lang('Delete')}
iconName="close"
onClick={() => handleRemoveOption(option.id)}
/>
)}
</div>
</div>
);
})}
{isCorrectAnswerInvalid && (
<IslandDescription key="correct-answer-error" className={styles.errorDescription}>
{lang('PollsChooseCorrect')}
</IslandDescription>
)}
</Island>
<IslandDescription>
{remainingOptionsCount > 0 ? (
lang('PollModalAddMoreText', { count: remainingOptionsCount }, { pluralValue: remainingOptionsCount })
) : lang('PollModalAddNoMore')}
</IslandDescription>
<IslandTitle>{lang('PollModalSettingsTitle')}</IslandTitle>
<Island>
{!isChannel && (
<SettingRow
iconName="eye"
iconBackgroundColor={ICON_COLORS.anonymous}
label={lang('PollAnswersVisible')}
description={lang('PollAnswersVisibleDescription')}
checked={isPublic}
onChange={setIsPublic}
/>
)}
<SettingRow
iconName="choice-selected"
iconBackgroundColor={ICON_COLORS.multiple}
label={lang('PollMultiple')}
description={lang('PollMultipleDescription')}
checked={isMultipleAnswers}
onChange={handleMultipleAnswersChange}
/>
{!isChannel && (
<SettingRow
iconName="add-filled"
iconBackgroundColor={ICON_COLORS.addAnswers}
label={lang('PollAllowAddingAnswers')}
description={lang('PollAllowAddingAnswersDescription')}
checked={canAddAnswers}
disabled={isAddAnswersDisabled}
locked={isAddAnswersDisabled}
onChange={setCanAddAnswers}
/>
)}
<SettingRow
iconName="reload"
iconBackgroundColor={ICON_COLORS.revote}
label={lang('PollAllowVoteChanges')}
description={lang('PollAllowVoteChangesDescription')}
checked={canRevote}
onChange={setCanRevote}
/>
<SettingRow
iconName="replace-round"
iconBackgroundColor={ICON_COLORS.shuffle}
label={lang('PollRandomOrder')}
description={lang('PollRandomOrderDescription')}
checked={shouldShuffleAnswers}
onChange={setShouldShuffleAnswers}
/>
<SettingRow
iconName="check-filled"
iconBackgroundColor={ICON_COLORS.quiz}
label={lang('PollQuiz')}
description={lang('PollQuizDescription')}
checked={isQuizMode}
onChange={handleQuizModeChange}
/>
<SettingRow
iconName="timer-filled"
iconBackgroundColor={ICON_COLORS.duration}
label={lang('PollLimitedDuration')}
description={lang('PollLimitedDurationDescription')}
checked={hasLimitedDuration}
onChange={handleLimitedDurationChange}
/>
{hasLimitedDuration ? (
<>
<div ref={durationMenuRef} className={styles.durationMenuWrapper}>
<ValueRow
label={lang('PollDuration')}
value={closeDateLabel}
onClick={handleDurationMenuOpen}
/>
<Menu
isOpen={isDurationMenuOpen}
className={buildClassName('with-menu-transitions', styles.durationMenu)}
positionX="right"
positionY="bottom"
autoClose
onClose={handleDurationMenuClose}
onCloseAnimationEnd={handleDurationMenuHide}
>
{CLOSE_PERIOD_OPTIONS.map((period) => (
<MenuItem
key={period}
disabled={period > pollClosePeriodMax}
onClick={() => handleSelectClosePeriod(period)}
>
{formatShortDuration(lang, period)}
</MenuItem>
))}
<MenuItem onClick={handleOpenCloseDatePicker}>
{lang('PollDurationOther')}
</MenuItem>
</Menu>
</div>
<SettingRow
label={lang('PollHideResultsUntilClose')}
checked={shouldHideResultsUntilClose}
onChange={setShouldHideResultsUntilClose}
/>
</>
) : undefined}
</Island>
{isQuizMode && (
<>
<IslandTitle>{lang('PollsSolutionTitle')}</IslandTitle>
<Island>
<TextArea
className={styles.textArea}
value={solution}
label={lang('PollsSolutionTitle')}
maxLength={MAX_SOLUTION_LENGTH}
noReplaceNewlines
onChange={handleSolutionChange}
/>
</Island>
<IslandDescription>{lang('CreatePollExplanationInfo')}</IslandDescription>
</>
)}
</Modal>
{calendar}
<CalendarModal
isOpen={isCloseDatePickerOpen}
selectedAt={closeDatePickerSelectedAt}
maxAt={maxCloseDateAt}
isFutureMode
withTimePicker
submitButtonLabel={lang('Save')}
onClose={handleCloseCloseDatePicker}
onSubmit={handleCloseDateSave}
/>
<PaymentMessageConfirmDialog
isOpen={isPaymentMessageConfirmDialogOpen}
onClose={closeConfirmDialog}
userName={chat ? getPeerTitle(lang, chat) : undefined}
messagePriceInStars={paidMessagesStars || 0}
messagesCount={1}
shouldAutoApprove={shouldAutoApprove}
setAutoApprove={setAutoApprove}
confirmHandler={dialogHandler}
/>
</>
);
};
const SettingRow = ({
iconName,
iconBackgroundColor,
label,
description,
checked,
disabled,
locked,
onChange,
}: SettingRowProps) => {
return (
<Interactive asLabel clickable disabled={disabled}>
<Control inputEnd>
<Switch checked={checked} disabled={disabled} locked={locked} onChange={onChange} />
<ControlIcon iconName={iconName} backgroundColor={iconBackgroundColor} />
<ControlLabel>{label}</ControlLabel>
{description !== undefined ? (
<ControlDescription>{description}</ControlDescription>
) : undefined}
</Control>
</Interactive>
);
};
const ValueRow = ({
iconName,
iconBackgroundColor,
label,
value,
onClick,
}: ValueRowProps) => {
return (
<Interactive clickable onClick={onClick}>
<Control>
<ControlIcon iconName={iconName} backgroundColor={iconBackgroundColor} />
<ControlLabel>{label}</ControlLabel>
<ControlAfter className={styles.rowValue}>
{value}
</ControlAfter>
</Control>
</Interactive>
);
};
function createPollOption(text = ''): PollOption {
return {
id: generateUniqueNumberId().toString(),
text,
};
}
function normalizeOptions(options: PollOption[], maxOptionsCount: number) {
const nextOptions = [...options];
while (
nextOptions.length > 1
&& !nextOptions[nextOptions.length - 1].text.trim()
&& !nextOptions[nextOptions.length - 2].text.trim()
) {
nextOptions.pop();
}
if (!nextOptions.length) {
nextOptions.push(createPollOption());
}
if (nextOptions.length < maxOptionsCount && nextOptions[nextOptions.length - 1].text.trim()) {
nextOptions.push(createPollOption());
}
return nextOptions;
}
export default memo(withGlobal<OwnProps>(
(global, { modal }): Complete<StateProps> => {
const tabState = selectTabState(global);
const { chatId } = modal;
const chat = selectChat(global, chatId);
return {
chat,
isChannel: chat ? isChatChannel(chat) : undefined,
pollMaxAnswers: global.appConfig.pollMaxAnswers,
pollClosePeriodMax: global.appConfig.pollClosePeriodMax,
paidMessagesStars: selectPeerPaidMessagesStars(global, chatId),
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen,
starsBalance: global.stars?.balance.amount || 0,
isStarsBalanceModalOpen: Boolean(tabState.starsBalanceModal),
isSilentPosting: chat ? getChatNotifySettings(
chat,
selectNotifyDefaults(global),
selectNotifyException(global, chat.id),
)?.isSilentPosting : undefined,
};
},
)(PollModal));

View File

@ -66,6 +66,7 @@ const ConfirmDialog: FC<OwnProps> = ({
header={header} header={header}
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
isNativeDialog
onCloseAnimationEnd={onCloseAnimationEnd} onCloseAnimationEnd={onCloseAnimationEnd}
> >
{text && text.split('\\n').map((textPart) => ( {text && text.split('\\n').map((textPart) => (

View File

@ -4,6 +4,10 @@
z-index: var(--z-modal); z-index: var(--z-modal);
color: var(--color-text); color: var(--color-text);
&[open] {
display: block;
}
&.confirm, &.confirm,
&.pin { &.pin {
z-index: var(--z-modal-confirm); z-index: var(--z-modal-confirm);
@ -295,4 +299,23 @@
right: 0.875rem; right: 0.875rem;
} }
} }
dialog.Modal {
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
color: var(--color-text);
background: transparent;
outline: none;
&::backdrop {
background: transparent;
}
}
} }

View File

@ -1,6 +1,8 @@
import type { ElementRef, TeactNode } from '../../lib/teact/teact'; import type { ElementRef, TeactNode } from '../../lib/teact/teact';
import type React from '../../lib/teact/teact'; import type React from '../../lib/teact/teact';
import { beginHeavyAnimation, useEffect, useRef } from '../../lib/teact/teact'; import {
beginHeavyAnimation, useEffect, useLayoutEffect, useRef,
} from '../../lib/teact/teact';
import type { TextPart } from '../../types'; import type { TextPart } from '../../types';
@ -41,6 +43,7 @@ export type OwnProps = {
isBackButton?: boolean; isBackButton?: boolean;
noBackdrop?: boolean; noBackdrop?: boolean;
noBackdropClose?: boolean; noBackdropClose?: boolean;
isNativeDialog?: boolean;
children: React.ReactNode; children: React.ReactNode;
style?: string; style?: string;
dialogStyle?: string; dialogStyle?: string;
@ -64,6 +67,7 @@ const Modal = (props: OwnProps) => {
isOpen, isOpen,
noBackdropClose, noBackdropClose,
noFreezeOnClose, noFreezeOnClose,
isNativeDialog,
onClose, onClose,
onCloseAnimationEnd, onCloseAnimationEnd,
onEnter, onEnter,
@ -72,7 +76,7 @@ const Modal = (props: OwnProps) => {
const { const {
ref: modalRef, ref: modalRef,
shouldRender, shouldRender,
} = useShowTransition({ } = useShowTransition<HTMLElement>({
isOpen, isOpen,
withShouldRender: true, withShouldRender: true,
onCloseAnimationEnd, onCloseAnimationEnd,
@ -117,6 +121,8 @@ const Modal = (props: OwnProps) => {
} = useContextMenuHandlers(moreButtonRef); } = useContextMenuHandlers(moreButtonRef);
const actualDialogRef = dialogRef || localDialogRef; const actualDialogRef = dialogRef || localDialogRef;
const divModalRef = modalRef as ElementRef<HTMLDivElement>;
const nativeDialogRef = modalRef as ElementRef<HTMLDialogElement>;
const getRootElement = useLastCallback(() => actualDialogRef.current); const getRootElement = useLastCallback(() => actualDialogRef.current);
const getTriggerElement = useLastCallback(() => moreButtonRef.current); const getTriggerElement = useLastCallback(() => moreButtonRef.current);
@ -150,6 +156,52 @@ const Modal = (props: OwnProps) => {
), [isOpen, onClose, handleEnter]); ), [isOpen, onClose, handleEnter]);
useEffect(() => (isOpen && modalRef.current ? trapFocus(modalRef.current) : undefined), [isOpen, modalRef]); useEffect(() => (isOpen && modalRef.current ? trapFocus(modalRef.current) : undefined), [isOpen, modalRef]);
useLayoutEffect(() => {
if (!isNativeDialog || !shouldRender) {
return undefined;
}
const dialog = nativeDialogRef.current;
if (!dialog) {
return undefined;
}
if (!dialog.open) {
dialog.showModal();
}
return () => {
if (dialog.open) {
dialog.close();
}
};
}, [isNativeDialog, nativeDialogRef, shouldRender]);
useEffect(() => {
if (!isNativeDialog || !shouldRender) {
return undefined;
}
const dialog = nativeDialogRef.current;
if (!dialog) {
return undefined;
}
const handleCancel = (event: Event) => {
event.preventDefault();
if (isOpen) {
onClose();
}
};
dialog.addEventListener('cancel', handleCancel);
return () => {
dialog.removeEventListener('cancel', handleCancel);
};
}, [isNativeDialog, isOpen, nativeDialogRef, onClose, shouldRender]);
useHistoryBack({ useHistoryBack({
isActive: isOpen, isActive: isOpen,
onBack: onClose, onBack: onClose,
@ -218,14 +270,9 @@ const Modal = (props: OwnProps) => {
dialogClassName, dialogClassName,
); );
return ( function renderContent() {
<Portal> return (
<div <>
ref={modalRef}
className={fullClassName}
tabIndex={-1}
role="dialog"
>
<div className="modal-container"> <div className="modal-container">
<div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} /> <div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} />
{withBalanceBar && ( {withBalanceBar && (
@ -274,7 +321,30 @@ const Modal = (props: OwnProps) => {
</div> </div>
</div> </div>
</div> </div>
</div> </>
);
}
return (
<Portal>
{isNativeDialog ? (
<dialog
ref={nativeDialogRef}
className={fullClassName}
aria-modal="true"
>
{renderContent()}
</dialog>
) : (
<div
ref={divModalRef}
className={fullClassName}
tabIndex={-1}
role="dialog"
>
{renderContent()}
</div>
)}
</Portal> </Portal>
); );
}; };

View File

@ -126,9 +126,16 @@ addActionHandler('clickBotInlineButton', (global, actions, payload): ActionRetur
break; break;
} }
case 'requestPoll': case 'requestPoll': {
actions.openPollModal({ isQuiz: button.isQuiz, tabId }); actions.openPollModal({
chatId,
threadId,
messageListType: 'thread',
isQuiz: button.isQuiz,
tabId,
});
break; break;
}
case 'requestPhone': { case 'requestPhone': {
const user = global.currentUserId ? selectUser(global, global.currentUserId) : undefined; const user = global.currentUserId ? selectUser(global, global.currentUserId) : undefined;

View File

@ -1382,6 +1382,19 @@ addActionHandler('sendPollVote', (global, actions, payload): ActionReturnType =>
} }
}); });
addActionHandler('appendPollAnswer', async (global, actions, payload): Promise<void> => {
const {
chatId, messageId, text,
} = payload;
const chat = selectChat(global, chatId);
if (!chat) {
return;
}
await callApi('appendPollAnswer', { chat, messageId, text });
});
addActionHandler('toggleTodoCompleted', (global, actions, payload): ActionReturnType => { addActionHandler('toggleTodoCompleted', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, completedIds, incompletedIds } = payload; const { chatId, messageId, completedIds, incompletedIds } = payload;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);

View File

@ -754,25 +754,24 @@ addActionHandler('exitMessageSelectMode', (global, actions, payload): ActionRetu
}); });
addActionHandler('openPollModal', (global, actions, payload): ActionReturnType => { addActionHandler('openPollModal', (global, actions, payload): ActionReturnType => {
const { isQuiz, tabId = getCurrentTabId() } = payload || {}; const {
chatId,
threadId,
messageListType,
isQuiz,
tabId = getCurrentTabId(),
} = payload;
return updateTabState(global, { return updateTabState(global, {
pollModal: { pollModal: {
isOpen: true, chatId,
threadId,
messageListType,
isQuiz, isQuiz,
}, },
}, tabId); }, tabId);
}); });
addTabStateResetterAction('closePollModal', 'pollModal');
addActionHandler('closePollModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
pollModal: {
isOpen: false,
},
}, tabId);
});
addActionHandler('openTodoListModal', (global, actions, payload): ActionReturnType => { addActionHandler('openTodoListModal', (global, actions, payload): ActionReturnType => {
const { const {

View File

@ -464,10 +464,6 @@ export const INITIAL_TAB_STATE: TabState = {
byChatId: {}, byChatId: {},
}, },
pollModal: {
isOpen: false,
},
requestedTranslations: { requestedTranslations: {
byChatId: {}, byChatId: {},
}, },

View File

@ -1528,6 +1528,11 @@ export interface ActionPayloads {
messageId: number; messageId: number;
options: string[]; options: string[];
}; };
appendPollAnswer: {
chatId: string;
messageId: number;
text: string;
};
toggleTodoCompleted: { toggleTodoCompleted: {
chatId: string; chatId: string;
messageId: number; messageId: number;
@ -2421,9 +2426,12 @@ export interface ActionPayloads {
refreshLangPackFromCache: { refreshLangPackFromCache: {
langCode: string; langCode: string;
}; };
openPollModal: ({ openPollModal: {
chatId: string;
threadId?: ThreadId;
messageListType: MessageListType;
isQuiz?: boolean; isQuiz?: boolean;
} & WithTabId) | undefined; } & WithTabId;
closePollModal: WithTabId | undefined; closePollModal: WithTabId | undefined;
openTodoListModal: { openTodoListModal: {
chatId: string; chatId: string;

View File

@ -82,6 +82,7 @@ import type {
MediaViewerMedia, MediaViewerMedia,
MediaViewerOrigin, MediaViewerOrigin,
MessageList, MessageList,
MessageListType,
MiddleSearchParams, MiddleSearchParams,
NewChatMembersProgress, NewChatMembersProgress,
PaymentStep, PaymentStep,
@ -567,8 +568,10 @@ export type TabState = {
filter?: ApiChatType[]; filter?: ApiChatType[];
}; };
pollModal: { pollModal?: {
isOpen: boolean; chatId: string;
threadId?: ThreadId;
messageListType: MessageListType;
isQuiz?: boolean; isQuiz?: boolean;
}; };

View File

@ -0,0 +1,545 @@
import {
useEffect,
useRef,
useSignal,
useState,
} from '../lib/teact/teact';
import { requestMeasure, requestMutation } from '../lib/fasterdom/fasterdom';
import { areArraysShallowEqual } from '../util/areShallowEqual';
import buildStyle from '../util/buildStyle';
import getPointerPosition from '../util/events/getPointerPosition';
import { REM } from '../components/common/helpers/mediaDimensions';
import useLastCallback from './useLastCallback';
type ReorderableId = number | string;
type ReorderableListOptions<T extends ReorderableId> = {
itemIds: T[];
isDisabled?: boolean;
withAutoscroll?: boolean;
onReorder: (itemIds: T[]) => void;
};
type DragState<T extends ReorderableId> = {
id: T;
offsetY: number;
left: number;
top: number;
centerY: number;
translateY: number;
width: number;
height: number;
};
type RowProps = {
ref: (element?: HTMLElement) => void;
};
type DragElementProps = {
ref: (element?: HTMLElement) => void;
};
type HandleProps = {
ref: (element?: HTMLElement) => void;
role: 'button';
tabIndex: number;
onMouseDown: (event: React.MouseEvent) => void;
onTouchStart: (event: React.TouchEvent) => void;
onKeyDown: (event: React.KeyboardEvent) => void;
};
const AUTOSCROLL_EDGE_DISTANCE = 4 * REM;
const AUTOSCROLL_MAX_DELTA = 0.5 * REM;
export default function useReorderableList<T extends ReorderableId>({
itemIds,
isDisabled,
withAutoscroll,
onReorder,
}: ReorderableListOptions<T>) {
const rowElementsRef = useRef(new Map<T, HTMLElement>());
const dragElementsRef = useRef(new Map<T, HTMLElement>());
const handleElementsRef = useRef(new Map<T, HTMLElement>());
const itemIdsRef = useRef(itemIds);
const pendingFocusIdRef = useRef<T | undefined>();
const isAutoscrollScheduledRef = useRef(false);
const scrollContainerRef = useRef<HTMLElement | undefined>();
const lastPointerYRef = useRef<number | undefined>();
const [getDragState, setDragState] = useSignal<DragState<T> | undefined>();
const [draggedId, setDraggedId] = useState<T | undefined>();
const [draggedHeight, setDraggedHeight] = useState<number | undefined>();
useEffect(() => {
itemIdsRef.current = itemIds;
const pendingFocusId = pendingFocusIdRef.current;
if (pendingFocusId === undefined) {
return;
}
pendingFocusIdRef.current = undefined;
handleElementsRef.current?.get(pendingFocusId)?.focus();
}, [itemIds]);
const reorder = useLastCallback((fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) {
return;
}
const nextItemIds = moveItem(itemIdsRef.current, fromIndex, toIndex);
itemIdsRef.current = nextItemIds;
onReorder(nextItemIds);
});
const reorderDraggedItem = useLastCallback((itemId: T, centerY: number, isMovingDown: boolean) => {
const currentItemIds = itemIdsRef.current;
const currentIndex = currentItemIds.indexOf(itemId);
if (currentIndex < 0) {
return;
}
const withoutDraggedItemIds = currentItemIds.filter((currentId) => currentId !== itemId);
const targetIndex = getTargetIndex(withoutDraggedItemIds, rowElementsRef.current, centerY, isMovingDown);
const nextItemIds = [...withoutDraggedItemIds];
nextItemIds.splice(targetIndex, 0, itemId);
if (areArraysShallowEqual(currentItemIds, nextItemIds)) {
return;
}
itemIdsRef.current = nextItemIds;
onReorder(nextItemIds);
});
const updateDragFromPointer = useLastCallback((y: number, isMovingDown?: boolean) => {
const dragState = getDragState();
if (!dragState) {
return;
}
const bounds = getVerticalBounds(itemIdsRef.current, rowElementsRef.current);
const nextTop = y - dragState.offsetY;
const clampedTop = bounds
? clamp(nextTop, bounds.top, Math.max(bounds.top, bounds.bottom - dragState.height))
: nextTop;
const translateY = clampedTop - dragState.top;
const centerY = clampedTop + dragState.height / 2;
const nextDragState = {
...dragState,
centerY,
translateY,
};
setDragState(nextDragState);
requestMutation(() => {
const currentDragState = getDragState();
const dragElement = dragElementsRef.current.get(dragState.id);
if (currentDragState !== nextDragState || !dragElement) {
return;
}
dragElement.style.transform = `translateY(${nextDragState.translateY}px)`;
});
reorderDraggedItem(dragState.id, centerY, isMovingDown ?? centerY > dragState.centerY);
});
const scheduleAutoscroll = useLastCallback(() => {
isAutoscrollScheduledRef.current = true;
requestMeasure(() => {
const dragState = getDragState();
const scrollContainer = scrollContainerRef.current;
const pointerY = lastPointerYRef.current;
if (!dragState || !scrollContainer || pointerY === undefined) {
isAutoscrollScheduledRef.current = false;
return;
}
const autoscrollState = getAutoscrollState(scrollContainer, pointerY);
if (!autoscrollState) {
isAutoscrollScheduledRef.current = false;
return;
}
requestMutation(() => {
const currentDragState = getDragState();
if (currentDragState !== dragState || scrollContainerRef.current !== scrollContainer) {
if (!currentDragState || scrollContainerRef.current !== scrollContainer) {
isAutoscrollScheduledRef.current = false;
}
return;
}
scrollContainer.scrollTop = autoscrollState.targetScrollTop;
if (autoscrollState.delta) {
requestMeasure(() => {
updateDragFromPointer(pointerY, autoscrollState.delta > 0);
});
}
scheduleAutoscroll();
});
});
});
const restartAutoscrollIfNeeded = useLastCallback((pointerY: number) => {
const scrollContainer = scrollContainerRef.current;
if (
isAutoscrollScheduledRef.current
|| !scrollContainer
|| !getAutoscrollState(scrollContainer, pointerY)
) {
return;
}
scheduleAutoscroll();
});
const handleDrag = useLastCallback((event: MouseEvent | TouchEvent) => {
if (event.cancelable) {
event.preventDefault();
}
const { y } = getPointerPosition(event);
lastPointerYRef.current = y;
updateDragFromPointer(y);
restartAutoscrollIfNeeded(y);
});
const handleRelease = useLastCallback(() => {
const dragState = getDragState();
const dragElement = dragState ? dragElementsRef.current.get(dragState.id) : undefined;
if (dragState && dragElement) {
const releasedId = dragState.id;
requestMutation(() => {
if (
getDragState()?.id === releasedId
|| dragElementsRef.current.get(releasedId) !== dragElement
) {
return;
}
dragElement.style.transform = '';
});
}
isAutoscrollScheduledRef.current = false;
scrollContainerRef.current = undefined;
lastPointerYRef.current = undefined;
setDragState(undefined);
setDraggedId(undefined);
setDraggedHeight(undefined);
});
useEffect(() => {
if (draggedId === undefined) {
return undefined;
}
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', handleRelease);
document.addEventListener('touchmove', handleDrag, { passive: true });
document.addEventListener('touchend', handleRelease);
document.addEventListener('touchcancel', handleRelease);
return () => {
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', handleRelease);
document.removeEventListener('touchmove', handleDrag);
document.removeEventListener('touchend', handleRelease);
document.removeEventListener('touchcancel', handleRelease);
};
}, [draggedId]);
const startDrag = useLastCallback((event: React.MouseEvent | React.TouchEvent, itemId: T) => {
if (isDisabled || itemIdsRef.current.length < 2) {
return;
}
if ('button' in event && event.button !== 0) {
return;
}
event.preventDefault();
event.stopPropagation();
const rowElement = rowElementsRef.current.get(itemId);
if (!rowElement) {
return;
}
const { y } = getPointerPosition(event);
lastPointerYRef.current = y;
const {
left, top, width, height,
} = rowElement.getBoundingClientRect();
const nextDragState = {
id: itemId,
offsetY: y - top,
left,
top,
centerY: top + height / 2,
translateY: 0,
width,
height,
};
setDragState(nextDragState);
setDraggedId(itemId);
setDraggedHeight(height);
scrollContainerRef.current = withAutoscroll ? findScrollableContainer(rowElement) : undefined;
restartAutoscrollIfNeeded(y);
});
const handleKeyboardReorder = useLastCallback((event: React.KeyboardEvent, itemId: T) => {
if (isDisabled || itemIdsRef.current.length < 2) {
return;
}
const currentIndex = itemIdsRef.current.indexOf(itemId);
if (currentIndex < 0) {
return;
}
const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
if (!delta) {
return;
}
const nextIndex = currentIndex + delta;
if (nextIndex < 0 || nextIndex >= itemIdsRef.current.length) {
return;
}
event.preventDefault();
event.stopPropagation();
pendingFocusIdRef.current = itemId;
reorder(currentIndex, nextIndex);
});
const getRowProps = useLastCallback((itemId: T): RowProps => {
return {
ref: (element?: HTMLElement) => {
if (element) {
rowElementsRef.current?.set(itemId, element);
} else {
rowElementsRef.current?.delete(itemId);
}
},
};
});
const getHandleProps = useLastCallback((itemId: T): HandleProps => {
return {
ref: (element?: HTMLElement) => {
if (element) {
handleElementsRef.current?.set(itemId, element);
} else {
handleElementsRef.current?.delete(itemId);
}
},
role: 'button',
tabIndex: isDisabled || itemIds.length < 2 ? -1 : 0,
onMouseDown: (event) => startDrag(event, itemId),
onTouchStart: (event) => startDrag(event, itemId),
onKeyDown: (event) => handleKeyboardReorder(event, itemId),
};
});
const getDragElementProps = useLastCallback((itemId: T): DragElementProps => {
return {
ref: (element?: HTMLElement) => {
if (element) {
dragElementsRef.current?.set(itemId, element);
const dragState = getDragState();
if (dragState?.id === itemId) {
element.style.transform = `translateY(${dragState.translateY}px)`;
}
} else {
dragElementsRef.current?.delete(itemId);
}
},
};
});
const getPlaceholderStyle = useLastCallback((itemId: T) => {
if (draggedId !== itemId || draggedHeight === undefined) {
return undefined;
}
return `height: ${draggedHeight}px`;
});
const getDragStyle = useLastCallback((itemId: T) => {
const dragState = getDragState();
if (dragState?.id !== itemId) {
return undefined;
}
return buildStyle(
'position: fixed',
`left: ${dragState.left}px`,
`top: ${dragState.top}px`,
`transform: translateY(${dragState.translateY}px)`,
`width: ${dragState.width}px`,
`height: ${dragState.height}px`,
);
});
return {
draggedId,
getRowProps,
getDragElementProps,
getHandleProps,
getPlaceholderStyle,
getDragStyle,
};
}
function moveItem<T>(items: T[], fromIndex: number, toIndex: number) {
const nextItems = [...items];
const [item] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, item);
return nextItems;
}
function getTargetIndex<T extends ReorderableId>(
itemIds: T[],
rowElements: Map<T, HTMLElement>,
centerY: number,
isMovingDown: boolean,
) {
const measuredItems = itemIds.reduce<{ id: T; top: number; height: number }[]>((result, id) => {
const element = rowElements.get(id);
if (!element) {
return result;
}
const { top, height } = element.getBoundingClientRect();
result.push({ id, top, height });
return result;
}, []);
measuredItems.sort((a, b) => a.top - b.top);
if (isMovingDown) {
let targetIndex = 0;
for (const { id, top } of measuredItems) {
if (centerY >= top) {
targetIndex = itemIds.indexOf(id) + 1;
}
}
return targetIndex;
}
for (const { id, top, height } of measuredItems) {
if (centerY <= top + height) {
return itemIds.indexOf(id);
}
}
return itemIds.length;
}
function getVerticalBounds<T extends ReorderableId>(itemIds: T[], rowElements: Map<T, HTMLElement>) {
return itemIds.reduce<{ top: number; bottom: number } | undefined>((bounds, id) => {
const element = rowElements.get(id);
if (!element) {
return bounds;
}
const { top, bottom } = element.getBoundingClientRect();
if (!bounds) {
return { top, bottom };
}
return {
top: Math.min(bounds.top, top),
bottom: Math.max(bounds.bottom, bottom),
};
}, undefined);
}
function findScrollableContainer(element: HTMLElement) {
let currentElement = element.parentElement;
while (currentElement) {
const isScrollable = currentElement.scrollHeight > currentElement.clientHeight;
if (isScrollable) {
return currentElement;
}
currentElement = currentElement.parentElement;
}
return undefined;
}
function getAutoscrollState(scrollContainer: HTMLElement, pointerY: number) {
const { top, bottom } = scrollContainer.getBoundingClientRect();
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const distanceToTop = pointerY - top;
const distanceToBottom = bottom - pointerY;
const maxScrollTop = scrollHeight - clientHeight;
if (distanceToTop < AUTOSCROLL_EDGE_DISTANCE && scrollTop > 0) {
const distanceIntoEdge = AUTOSCROLL_EDGE_DISTANCE - Math.max(distanceToTop, 0);
const delta = -Math.min(
getAutoscrollSpeed(distanceIntoEdge),
scrollTop,
);
return {
delta,
targetScrollTop: scrollTop + delta,
};
}
if (distanceToBottom < AUTOSCROLL_EDGE_DISTANCE && scrollTop < maxScrollTop) {
const distanceIntoEdge = AUTOSCROLL_EDGE_DISTANCE - Math.max(distanceToBottom, 0);
const delta = Math.min(
getAutoscrollSpeed(distanceIntoEdge),
maxScrollTop - scrollTop,
);
return {
delta,
targetScrollTop: scrollTop + delta,
};
}
return undefined;
}
function getAutoscrollSpeed(distanceIntoEdge: number) {
return clamp(
(distanceIntoEdge / AUTOSCROLL_EDGE_DISTANCE) * AUTOSCROLL_MAX_DELTA,
1,
AUTOSCROLL_MAX_DELTA,
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}

View File

@ -1812,6 +1812,7 @@ messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer r
messages.declineUrlAuth#35436bbc url:string = Bool; messages.declineUrlAuth#35436bbc url:string = Bool;
messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool;
messages.composeMessageWithAI#fd426afe flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string change_tone:flags.2?string = messages.ComposedMessageWithAI; messages.composeMessageWithAI#fd426afe flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string change_tone:flags.2?string = messages.ComposedMessageWithAI;
messages.addPollAnswer#19bc4b6d peer:InputPeer msg_id:int answer:PollAnswer = Updates;
messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; messages.getUnreadPollVotes#43286cf2 flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages;
messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory; messages.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory;
updates.getState#edd4882a = updates.State; updates.getState#edd4882a = updates.State;

View File

@ -250,6 +250,7 @@
"messages.deleteTopicHistory", "messages.deleteTopicHistory",
"messages.toggleTodoCompleted", "messages.toggleTodoCompleted",
"messages.appendTodoList", "messages.appendTodoList",
"messages.addPollAnswer",
"messages.summarizeText", "messages.summarizeText",
"messages.editChatCreator", "messages.editChatCreator",
"messages.getFutureChatCreatorAfterLeave", "messages.getFutureChatCreatorAfterLeave",

View File

@ -484,7 +484,7 @@ function renderChildren(
forceMoveToEnd = false, forceMoveToEnd = false,
namespace?: string, namespace?: string,
) { ) {
if (('props' in $new) && $new.props.teactFastList) { if ($new.type === VirtualType.Tag && $new.props.teactFastList) {
renderFastListChildren($current, $new, currentContext, currentEl, namespace); renderFastListChildren($current, $new, currentContext, currentEl, namespace);
return; return;
} }
@ -965,7 +965,7 @@ function getChildKeysByIndex(children: VirtualElementChildren) {
if (isNullable(key)) { if (isNullable(key)) {
if (DEBUG && isParentElement($child)) { if (DEBUG && isParentElement($child)) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('Missing `key` in `teactFastList`'); console.warn('Missing `key` in `teactFastList`', $child);
} }
key = `${INDEX_KEY_PREFIX}${index}`; key = `${INDEX_KEY_PREFIX}${index}`;

View File

@ -130,6 +130,8 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
starsSuggestedPostFutureMin: 300, starsSuggestedPostFutureMin: 300,
starsSuggestedPostFutureMax: 2678400, starsSuggestedPostFutureMax: 2678400,
starsSuggestedPostCommissionPermille: 850, starsSuggestedPostCommissionPermille: 850,
pollMaxAnswers: 12,
pollClosePeriodMax: 2628000,
noForwardsRequestExpirePeriod: 86400, noForwardsRequestExpirePeriod: 86400,
tonSuggestedPostCommissionPermille: 850, tonSuggestedPostCommissionPermille: 850,
todoItemLengthMax: 64, todoItemLengthMax: 64,

File diff suppressed because it is too large Load Diff

View File

@ -74,282 +74,286 @@ $icons-map: (
"chats-badge": "\f138", "chats-badge": "\f138",
"check": "\f139", "check": "\f139",
"check-bold": "\f13a", "check-bold": "\f13a",
"clock": "\f13b", "check-filled": "\f13b",
"clock-edit": "\f13c", "choice-selected": "\f13c",
"close": "\f13d", "clock": "\f13d",
"close-circle": "\f13e", "clock-edit": "\f13e",
"close-topic": "\f13f", "close": "\f13f",
"closed-gift": "\f140", "close-circle": "\f140",
"cloud-download": "\f141", "close-topic": "\f141",
"collapse": "\f142", "closed-gift": "\f142",
"collapse-modal": "\f143", "cloud-download": "\f143",
"colorize": "\f144", "collapse": "\f144",
"combine-craft": "\f145", "collapse-modal": "\f145",
"comments": "\f146", "colorize": "\f146",
"comments-sticker": "\f147", "combine-craft": "\f147",
"copy": "\f148", "comments": "\f148",
"copy-media": "\f149", "comments-sticker": "\f149",
"craft": "\f14a", "copy": "\f14a",
"crop": "\f14b", "copy-media": "\f14b",
"crown-take-off": "\f14c", "craft": "\f14c",
"crown-take-off-outline": "\f14d", "crop": "\f14d",
"crown-wear": "\f14e", "crown-take-off": "\f14e",
"crown-wear-outline": "\f14f", "crown-take-off-outline": "\f14f",
"darkmode": "\f150", "crown-wear": "\f150",
"data": "\f151", "crown-wear-outline": "\f151",
"delete": "\f152", "darkmode": "\f152",
"delete-filled": "\f153", "data": "\f153",
"delete-left": "\f154", "delete": "\f154",
"delete-user": "\f155", "delete-filled": "\f155",
"diamond": "\f156", "delete-left": "\f156",
"document": "\f157", "delete-user": "\f157",
"double-badge": "\f158", "diamond": "\f158",
"down": "\f159", "document": "\f159",
"download": "\f15a", "double-badge": "\f15a",
"dropdown-arrows": "\f15b", "down": "\f15b",
"eats": "\f15c", "download": "\f15c",
"edit": "\f15d", "dropdown-arrows": "\f15d",
"email": "\f15e", "eats": "\f15e",
"enter": "\f15f", "edit": "\f15f",
"expand": "\f160", "email": "\f160",
"expand-modal": "\f161", "enter": "\f161",
"eye": "\f162", "expand": "\f162",
"eye-crossed": "\f163", "expand-modal": "\f163",
"eye-crossed-outline": "\f164", "eye": "\f164",
"eye-outline": "\f165", "eye-crossed": "\f165",
"favorite": "\f166", "eye-crossed-outline": "\f166",
"favorite-filled": "\f167", "eye-outline": "\f167",
"file-badge": "\f168", "favorite": "\f168",
"flag": "\f169", "favorite-filled": "\f169",
"flip": "\f16a", "file-badge": "\f16a",
"folder": "\f16b", "flag": "\f16b",
"folder-badge": "\f16c", "flip": "\f16c",
"folder-tabs-bot": "\f16d", "folder": "\f16d",
"folder-tabs-channel": "\f16e", "folder-badge": "\f16e",
"folder-tabs-chat": "\f16f", "folder-tabs-bot": "\f16f",
"folder-tabs-chats": "\f170", "folder-tabs-channel": "\f170",
"folder-tabs-folder": "\f171", "folder-tabs-chat": "\f171",
"folder-tabs-group": "\f172", "folder-tabs-chats": "\f172",
"folder-tabs-star": "\f173", "folder-tabs-folder": "\f173",
"folder-tabs-user": "\f174", "folder-tabs-group": "\f174",
"fontsize": "\f175", "folder-tabs-star": "\f175",
"forums": "\f176", "folder-tabs-user": "\f176",
"forward": "\f177", "fontsize": "\f177",
"fragment": "\f178", "forums": "\f178",
"frozen-time": "\f179", "forward": "\f179",
"fullscreen": "\f17a", "fragment": "\f17a",
"gifs": "\f17b", "frozen-time": "\f17b",
"gift": "\f17c", "fullscreen": "\f17c",
"gift-transfer-inline": "\f17d", "gifs": "\f17d",
"group": "\f17e", "gift": "\f17e",
"group-filled": "\f17f", "gift-transfer-inline": "\f17f",
"grouped": "\f180", "group": "\f180",
"grouped-disable": "\f181", "group-filled": "\f181",
"hand-stop": "\f182", "grouped": "\f182",
"hand-stop-filled": "\f183", "grouped-disable": "\f183",
"hashtag": "\f184", "hand-stop": "\f184",
"hd-photo": "\f185", "hand-stop-filled": "\f185",
"heart": "\f186", "hashtag": "\f186",
"heart-outline": "\f187", "hd-photo": "\f187",
"help": "\f188", "heart": "\f188",
"info": "\f189", "heart-outline": "\f189",
"info-filled": "\f18a", "help": "\f18a",
"install": "\f18b", "info": "\f18b",
"italic": "\f18c", "info-filled": "\f18c",
"key": "\f18d", "install": "\f18d",
"keyboard": "\f18e", "italic": "\f18e",
"lamp": "\f18f", "key": "\f18f",
"language": "\f190", "keyboard": "\f190",
"large-pause": "\f191", "lamp": "\f191",
"large-play": "\f192", "language": "\f192",
"link": "\f193", "large-pause": "\f193",
"link-badge": "\f194", "large-play": "\f194",
"link-broken": "\f195", "link": "\f195",
"location": "\f196", "link-badge": "\f196",
"lock": "\f197", "link-broken": "\f197",
"lock-badge": "\f198", "location": "\f198",
"logout": "\f199", "lock": "\f199",
"loop": "\f19a", "lock-badge": "\f19a",
"mention": "\f19b", "logout": "\f19b",
"menu": "\f19c", "loop": "\f19c",
"message": "\f19d", "mention": "\f19d",
"message-failed": "\f19e", "menu": "\f19e",
"message-pending": "\f19f", "message": "\f19f",
"message-read": "\f1a0", "message-failed": "\f1a0",
"message-succeeded": "\f1a1", "message-pending": "\f1a1",
"microphone": "\f1a2", "message-read": "\f1a2",
"microphone-alt": "\f1a3", "message-succeeded": "\f1a3",
"monospace": "\f1a4", "microphone": "\f1a4",
"more": "\f1a5", "microphone-alt": "\f1a5",
"more-circle": "\f1a6", "monospace": "\f1a6",
"move-caption-down": "\f1a7", "more": "\f1a7",
"move-caption-up": "\f1a8", "more-circle": "\f1a8",
"mute": "\f1a9", "move-caption-down": "\f1a9",
"muted": "\f1aa", "move-caption-up": "\f1aa",
"my-notes": "\f1ab", "mute": "\f1ab",
"new-chat-filled": "\f1ac", "muted": "\f1ac",
"new-send": "\f1ad", "my-notes": "\f1ad",
"next": "\f1ae", "new-chat-filled": "\f1ae",
"next-link": "\f1af", "new-send": "\f1af",
"no-download": "\f1b0", "next": "\f1b0",
"no-share": "\f1b1", "next-link": "\f1b1",
"nochannel": "\f1b2", "no-download": "\f1b2",
"noise-suppression": "\f1b3", "no-share": "\f1b3",
"non-contacts": "\f1b4", "nochannel": "\f1b4",
"note": "\f1b5", "noise-suppression": "\f1b5",
"one-filled": "\f1b6", "non-contacts": "\f1b6",
"open-in-new-tab": "\f1b7", "note": "\f1b7",
"password-off": "\f1b8", "one-filled": "\f1b8",
"pause": "\f1b9", "open-in-new-tab": "\f1b9",
"permissions": "\f1ba", "password-off": "\f1ba",
"phone": "\f1bb", "pause": "\f1bb",
"phone-discard": "\f1bc", "permissions": "\f1bc",
"phone-discard-outline": "\f1bd", "phone": "\f1bd",
"photo": "\f1be", "phone-discard": "\f1be",
"pin": "\f1bf", "phone-discard-outline": "\f1bf",
"pin-badge": "\f1c0", "photo": "\f1c0",
"pin-list": "\f1c1", "pin": "\f1c1",
"pinned-chat": "\f1c2", "pin-badge": "\f1c2",
"pinned-message": "\f1c3", "pin-list": "\f1c3",
"pip": "\f1c4", "pinned-chat": "\f1c4",
"play": "\f1c5", "pinned-message": "\f1c5",
"play-story": "\f1c6", "pip": "\f1c6",
"poll": "\f1c7", "play": "\f1c7",
"poll-badge": "\f1c8", "play-story": "\f1c8",
"previous": "\f1c9", "poll": "\f1c9",
"previous-link": "\f1ca", "poll-badge": "\f1ca",
"privacy-policy": "\f1cb", "previous": "\f1cb",
"proof-of-ownership": "\f1cc", "previous-link": "\f1cc",
"quote": "\f1cd", "privacy-policy": "\f1cd",
"quote-text": "\f1ce", "proof-of-ownership": "\f1ce",
"radial-badge": "\f1cf", "quote": "\f1cf",
"rating-icons-level1": "\f1d0", "quote-text": "\f1d0",
"rating-icons-level10": "\f1d1", "radial-badge": "\f1d1",
"rating-icons-level2": "\f1d2", "rating-icons-level1": "\f1d2",
"rating-icons-level20": "\f1d3", "rating-icons-level10": "\f1d3",
"rating-icons-level3": "\f1d4", "rating-icons-level2": "\f1d4",
"rating-icons-level30": "\f1d5", "rating-icons-level20": "\f1d5",
"rating-icons-level4": "\f1d6", "rating-icons-level3": "\f1d6",
"rating-icons-level40": "\f1d7", "rating-icons-level30": "\f1d7",
"rating-icons-level5": "\f1d8", "rating-icons-level4": "\f1d8",
"rating-icons-level50": "\f1d9", "rating-icons-level40": "\f1d9",
"rating-icons-level6": "\f1da", "rating-icons-level5": "\f1da",
"rating-icons-level60": "\f1db", "rating-icons-level50": "\f1db",
"rating-icons-level7": "\f1dc", "rating-icons-level6": "\f1dc",
"rating-icons-level70": "\f1dd", "rating-icons-level60": "\f1dd",
"rating-icons-level8": "\f1de", "rating-icons-level7": "\f1de",
"rating-icons-level80": "\f1df", "rating-icons-level70": "\f1df",
"rating-icons-level9": "\f1e0", "rating-icons-level8": "\f1e0",
"rating-icons-level90": "\f1e1", "rating-icons-level80": "\f1e1",
"rating-icons-negative": "\f1e2", "rating-icons-level9": "\f1e2",
"readchats": "\f1e3", "rating-icons-level90": "\f1e3",
"recent": "\f1e4", "rating-icons-negative": "\f1e4",
"redo": "\f1e5", "readchats": "\f1e5",
"refund": "\f1e6", "recent": "\f1e6",
"reload": "\f1e7", "redo": "\f1e7",
"remove": "\f1e8", "refund": "\f1e8",
"remove-quote": "\f1e9", "reload": "\f1e9",
"reopen-topic": "\f1ea", "remove": "\f1ea",
"reorder-tabs": "\f1eb", "remove-quote": "\f1eb",
"replace": "\f1ec", "reopen-topic": "\f1ec",
"replies": "\f1ed", "reorder-tabs": "\f1ed",
"reply": "\f1ee", "replace": "\f1ee",
"reply-filled": "\f1ef", "replace-round": "\f1ef",
"revenue-split": "\f1f0", "replies": "\f1f0",
"revote": "\f1f1", "reply": "\f1f1",
"rotate": "\f1f2", "reply-filled": "\f1f2",
"save-story": "\f1f3", "revenue-split": "\f1f3",
"saved-messages": "\f1f4", "revote": "\f1f4",
"schedule": "\f1f5", "rotate": "\f1f5",
"scheduled": "\f1f6", "save-story": "\f1f6",
"sd-photo": "\f1f7", "saved-messages": "\f1f7",
"search": "\f1f8", "schedule": "\f1f8",
"select": "\f1f9", "scheduled": "\f1f9",
"select-filled": "\f1fa", "sd-photo": "\f1fa",
"sell": "\f1fb", "search": "\f1fb",
"sell-outline": "\f1fc", "select": "\f1fc",
"send": "\f1fd", "select-filled": "\f1fd",
"send-outline": "\f1fe", "sell": "\f1fe",
"settings": "\f1ff", "sell-outline": "\f1ff",
"settings-filled": "\f200", "send": "\f200",
"share-filled": "\f201", "send-outline": "\f201",
"share-screen": "\f202", "settings": "\f202",
"share-screen-outlined": "\f203", "settings-filled": "\f203",
"share-screen-stop": "\f204", "share-filled": "\f204",
"show-message": "\f205", "share-screen": "\f205",
"sidebar": "\f206", "share-screen-outlined": "\f206",
"skip-next": "\f207", "share-screen-stop": "\f207",
"skip-previous": "\f208", "show-message": "\f208",
"smallscreen": "\f209", "sidebar": "\f209",
"smile": "\f20a", "skip-next": "\f20a",
"sort": "\f20b", "skip-previous": "\f20b",
"sort-by-date": "\f20c", "smallscreen": "\f20c",
"sort-by-number": "\f20d", "smile": "\f20d",
"sort-by-price": "\f20e", "sort": "\f20e",
"speaker": "\f20f", "sort-by-date": "\f20f",
"speaker-muted-story": "\f210", "sort-by-number": "\f210",
"speaker-outline": "\f211", "sort-by-price": "\f211",
"speaker-story": "\f212", "speaker": "\f212",
"spoiler": "\f213", "speaker-muted-story": "\f213",
"spoiler-disable": "\f214", "speaker-outline": "\f214",
"sport": "\f215", "speaker-story": "\f215",
"star": "\f216", "spoiler": "\f216",
"stars-lock": "\f217", "spoiler-disable": "\f217",
"stars-refund": "\f218", "sport": "\f218",
"stats": "\f219", "star": "\f219",
"stealth-future": "\f21a", "stars-lock": "\f21a",
"stealth-past": "\f21b", "stars-refund": "\f21b",
"stickers": "\f21c", "stats": "\f21c",
"stop": "\f21d", "stealth-future": "\f21d",
"stop-raising-hand": "\f21e", "stealth-past": "\f21e",
"story-caption": "\f21f", "stickers": "\f21f",
"story-expired": "\f220", "stop": "\f220",
"story-priority": "\f221", "stop-raising-hand": "\f221",
"story-reply": "\f222", "story-caption": "\f222",
"strikethrough": "\f223", "story-expired": "\f223",
"tag": "\f224", "story-priority": "\f224",
"tag-add": "\f225", "story-reply": "\f225",
"tag-crossed": "\f226", "strikethrough": "\f226",
"tag-filter": "\f227", "tag": "\f227",
"tag-name": "\f228", "tag-add": "\f228",
"timer": "\f229", "tag-crossed": "\f229",
"toncoin": "\f22a", "tag-filter": "\f22a",
"tone": "\f22b", "tag-name": "\f22b",
"tools": "\f22c", "timer": "\f22c",
"topic-new": "\f22d", "timer-filled": "\f22d",
"trade": "\f22e", "toncoin": "\f22e",
"transcribe": "\f22f", "tone": "\f22f",
"truck": "\f230", "tools": "\f230",
"unarchive": "\f231", "topic-new": "\f231",
"underlined": "\f232", "trade": "\f232",
"understood": "\f233", "transcribe": "\f233",
"undo": "\f234", "truck": "\f234",
"unique-profile": "\f235", "unarchive": "\f235",
"unlist": "\f236", "underlined": "\f236",
"unlist-outline": "\f237", "understood": "\f237",
"unlock": "\f238", "undo": "\f238",
"unlock-badge": "\f239", "unique-profile": "\f239",
"unmute": "\f23a", "unlist": "\f23a",
"unpin": "\f23b", "unlist-outline": "\f23b",
"unread": "\f23c", "unlock": "\f23c",
"up": "\f23d", "unlock-badge": "\f23d",
"user": "\f23e", "unmute": "\f23e",
"user-filled": "\f23f", "unpin": "\f23f",
"user-online": "\f240", "unread": "\f240",
"user-stars": "\f241", "up": "\f241",
"user-tag": "\f242", "user": "\f242",
"video": "\f243", "user-filled": "\f243",
"video-outlined": "\f244", "user-online": "\f244",
"video-stop": "\f245", "user-stars": "\f245",
"view-once": "\f246", "user-tag": "\f246",
"voice-chat": "\f247", "video": "\f247",
"volume-1": "\f248", "video-outlined": "\f248",
"volume-2": "\f249", "video-stop": "\f249",
"volume-3": "\f24a", "view-once": "\f24a",
"warning": "\f24b", "voice-chat": "\f24b",
"web": "\f24c", "volume-1": "\f24c",
"webapp": "\f24d", "volume-2": "\f24d",
"word-wrap": "\f24e", "volume-3": "\f24e",
"zoom-in": "\f24f", "warning": "\f24f",
"zoom-out": "\f250", "web": "\f250",
"webapp": "\f251",
"word-wrap": "\f252",
"zoom-in": "\f253",
"zoom-out": "\f254",
); );

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,8 @@ export type FontIconName =
| 'chats-badge' | 'chats-badge'
| 'check' | 'check'
| 'check-bold' | 'check-bold'
| 'check-filled'
| 'choice-selected'
| 'clock' | 'clock'
| 'clock-edit' | 'clock-edit'
| 'close' | 'close'
@ -235,6 +237,7 @@ export type FontIconName =
| 'reopen-topic' | 'reopen-topic'
| 'reorder-tabs' | 'reorder-tabs'
| 'replace' | 'replace'
| 'replace-round'
| 'replies' | 'replies'
| 'reply' | 'reply'
| 'reply-filled' | 'reply-filled'
@ -296,6 +299,7 @@ export type FontIconName =
| 'tag-filter' | 'tag-filter'
| 'tag-name' | 'tag-name'
| 'timer' | 'timer'
| 'timer-filled'
| 'toncoin' | 'toncoin'
| 'tone' | 'tone'
| 'tools' | 'tools'

View File

@ -597,10 +597,23 @@ export interface LangPair {
'CreatePollAddOption': undefined; 'CreatePollAddOption': undefined;
'PollsChooseCorrect': undefined; 'PollsChooseCorrect': undefined;
'AskAQuestion': undefined; 'AskAQuestion': undefined;
'PollOptions': undefined; 'PollAnswersVisible': undefined;
'PollAnonymous': undefined; 'PollAnswersVisibleDescription': undefined;
'PollMultiple': undefined; 'PollMultiple': undefined;
'PollMultipleDescription': undefined;
'PollQuiz': undefined; 'PollQuiz': undefined;
'PollQuizDescription': undefined;
'PollAllowAddingAnswers': undefined;
'PollAllowAddingAnswersDescription': undefined;
'PollAllowVoteChanges': undefined;
'PollAllowVoteChangesDescription': undefined;
'PollRandomOrder': undefined;
'PollRandomOrderDescription': undefined;
'PollLimitedDuration': undefined;
'PollLimitedDurationDescription': undefined;
'PollDuration': undefined;
'PollHideResultsUntilClose': undefined;
'PollSelectCloseDate': undefined;
'PollsSolutionTitle': undefined; 'PollsSolutionTitle': undefined;
'CreatePollExplanationInfo': undefined; 'CreatePollExplanationInfo': undefined;
'VoipGroupPersonalAccount': undefined; 'VoipGroupPersonalAccount': undefined;
@ -2106,6 +2119,11 @@ export interface LangPair {
'AiMessageEditorFrom': undefined; 'AiMessageEditorFrom': undefined;
'AiMessageEditorTo': undefined; 'AiMessageEditorTo': undefined;
'ButtonHelp': undefined; 'ButtonHelp': undefined;
'PollModalQuestionTitle': undefined;
'PollModalOptionsTitle': undefined;
'PollModalSettingsTitle': undefined;
'PollModalAddNoMore': undefined;
'PollDurationOther': undefined;
} }
export interface LangPairWithVariables<V = LangVariable> { export interface LangPairWithVariables<V = LangVariable> {
@ -4224,6 +4242,9 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'FwdMessagesToChats': { 'FwdMessagesToChats': {
'count': V; 'count': V;
}; };
'PollModalAddMoreText': {
'count': V;
};
} }
export type RegularLangKey = keyof LangPair; export type RegularLangKey = keyof LangPair;
export type RegularLangKeyWithVariables = keyof LangPairWithVariables; export type RegularLangKeyWithVariables = keyof LangPairWithVariables;