Polls: Redesign modal (#6886)
This commit is contained in:
parent
6ceb7b6573
commit
a2d6d63853
@ -27,6 +27,7 @@ You are an expert in TypeScript, JavaScript, HTML, SCSS and Teact with deep expe
|
||||
- **Always extract styles to files** - avoid inline styles unless absolutely necessary.
|
||||
- **If file already imports styles**, check where they come from and add new styles there - don't create new style files.
|
||||
- Prefer rem units for all measurements. Exceptions are possible, but usually rare.
|
||||
- No complex or broad selectors. Prefer basic classes.
|
||||
|
||||
- **Code Style:**
|
||||
- Early returns.
|
||||
@ -176,7 +177,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => {
|
||||
* **StateProps**: data injected by `withGlobal` HOC
|
||||
* Merge them as `OwnProps & StateProps` when defining your component.
|
||||
* You can skip one or both if they are not used.
|
||||
* **Order rule**: list any function types *last* in your props definitions.
|
||||
* **Order rule**: list any handlers or functions *last* in your props definitions.
|
||||
* Do not pass unmemoized objects as props into memo() components.
|
||||
|
||||
### 3. Hooks
|
||||
|
||||
@ -41,8 +41,8 @@
|
||||
"npm": "^10.8 || ^11"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js}": "eslint --fix",
|
||||
"*.{css,scss}": "stylelint --fix"
|
||||
"*.{ts,tsx,js}": "eslint --cache --cache-location .cache/.eslintcache --fix",
|
||||
"*.{css,scss}": "stylelint --cache --cache-location .cache/.stylelintcache --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
|
||||
@ -118,6 +118,7 @@ export interface GramJsAppConfig extends LimitsConfig {
|
||||
ton_usd_rate?: number;
|
||||
ton_topup_url?: string;
|
||||
poll_answers_max?: number;
|
||||
poll_close_period_max?: number;
|
||||
todo_items_max?: number;
|
||||
todo_title_length_max?: number;
|
||||
todo_item_length_max?: number;
|
||||
@ -266,6 +267,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
|
||||
tonUsdRate: appConfig.ton_usd_rate,
|
||||
tonTopupUrl: appConfig.ton_topup_url,
|
||||
pollMaxAnswers: appConfig.poll_answers_max,
|
||||
pollClosePeriodMax: appConfig.poll_close_period_max,
|
||||
todoItemsMax: appConfig.todo_items_max,
|
||||
todoTitleLengthMax: appConfig.todo_title_length_max,
|
||||
todoItemLengthMax: appConfig.todo_item_length_max,
|
||||
|
||||
@ -235,18 +235,24 @@ export function buildInputPoll(
|
||||
});
|
||||
}),
|
||||
quiz: poll.isQuiz,
|
||||
closeDate: poll.closeDate,
|
||||
closePeriod: poll.closePeriod,
|
||||
hideResultsUntilClose: poll.shouldHideResultsUntilClose,
|
||||
revotingDisabled: poll.isRevoteDisabled,
|
||||
shuffleAnswers: poll.shouldShuffleAnswers,
|
||||
openAnswers: poll.canAddAnswers,
|
||||
multipleChoice: poll.isMultipleChoice,
|
||||
hash: DEFAULT_PRIMITIVES.BIGINT,
|
||||
});
|
||||
|
||||
const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity);
|
||||
const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity) || [];
|
||||
|
||||
return new GramJs.InputMediaPoll({
|
||||
poll: inputPoll,
|
||||
correctAnswers,
|
||||
attachedMedia: media?.attachedMedia,
|
||||
solution,
|
||||
solutionEntities: inputSolutionEntities,
|
||||
solutionEntities: solution ? inputSolutionEntities : undefined,
|
||||
solutionMedia: media?.solutionMedia,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1877,6 +1877,24 @@ export async function sendPollVote({
|
||||
}));
|
||||
}
|
||||
|
||||
export async function appendPollAnswer({
|
||||
chat, messageId, text,
|
||||
}: {
|
||||
chat: ApiChat;
|
||||
messageId: number;
|
||||
text: string;
|
||||
}) {
|
||||
const { id, accessHash } = chat;
|
||||
|
||||
await invokeRequest(new GramJs.messages.AddPollAnswer({
|
||||
peer: buildInputPeer(id, accessHash),
|
||||
msgId: messageId,
|
||||
answer: new GramJs.InputPollAnswer({
|
||||
text: buildInputTextWithEntities({ text }),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function toggleTodoCompleted({
|
||||
chat, messageId, completedIds, incompletedIds,
|
||||
}: {
|
||||
|
||||
@ -320,7 +320,8 @@ export interface ApiAppConfig {
|
||||
tonStargiftResaleCommissionPermille?: number;
|
||||
tonUsdRate?: number;
|
||||
tonTopupUrl: string;
|
||||
pollMaxAnswers?: number;
|
||||
pollMaxAnswers: number;
|
||||
pollClosePeriodMax: number;
|
||||
todoItemsMax: number;
|
||||
todoTitleLengthMax: number;
|
||||
todoItemLengthMax: number;
|
||||
|
||||
1
src/assets/font-icons/check-filled.svg
Normal file
1
src/assets/font-icons/check-filled.svg
Normal 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 |
1
src/assets/font-icons/choice-selected.svg
Normal file
1
src/assets/font-icons/choice-selected.svg
Normal 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 |
1
src/assets/font-icons/replace-round.svg
Normal file
1
src/assets/font-icons/replace-round.svg
Normal 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 |
1
src/assets/font-icons/timer-filled.svg
Normal file
1
src/assets/font-icons/timer-filled.svg
Normal 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 |
@ -685,17 +685,30 @@
|
||||
"Message" = "Message";
|
||||
"RecentStickers" = "Recently Used";
|
||||
"PollsChooseQuestion" = "Please enter a question.";
|
||||
"PollsChooseAnswers" = "Please enter at least two options.";
|
||||
"PollsChooseAnswers" = "Please enter at least one option.";
|
||||
"NewPoll" = "New Poll";
|
||||
"Create" = "Create";
|
||||
"OptionHint" = "Option";
|
||||
"CreatePollAddOption" = "Add an Option";
|
||||
"PollsChooseCorrect" = "Please choose the correct answer.";
|
||||
"AskAQuestion" = "Ask a Question";
|
||||
"PollOptions" = "Poll options";
|
||||
"PollAnonymous" = "Anonymous Poll";
|
||||
"PollMultiple" = "Multiple Answers";
|
||||
"PollQuiz" = "Quiz Mode";
|
||||
"PollAnswersVisible" = "Show Who Voted";
|
||||
"PollAnswersVisibleDescription" = "Display voter name on each option";
|
||||
"PollMultiple" = "Allow Multiple Answers";
|
||||
"PollMultipleDescription" = "Voters can select more than one option";
|
||||
"PollQuiz" = "Set Correct Answer";
|
||||
"PollQuizDescription" = "Mark one or more options as the right answer";
|
||||
"PollAllowAddingAnswers" = "Allow Adding Options";
|
||||
"PollAllowAddingAnswersDescription" = "Participants can suggest new options";
|
||||
"PollAllowVoteChanges" = "Allow Revoting";
|
||||
"PollAllowVoteChangesDescription" = "Voters can change their vote";
|
||||
"PollRandomOrder" = "Shuffle Options";
|
||||
"PollRandomOrderDescription" = "Answers appear in random order for each voter";
|
||||
"PollLimitedDuration" = "Limited Duration";
|
||||
"PollLimitedDurationDescription" = "Automatically close the poll at a set time";
|
||||
"PollDuration" = "Duration";
|
||||
"PollHideResultsUntilClose" = "Hide Results Until Close";
|
||||
"PollSelectCloseDate" = "Select Date";
|
||||
"PollsSolutionTitle" = "Explanation";
|
||||
"CreatePollExplanationInfo" = "Users will see this comment after choosing a wrong answer, good for educational purposes.";
|
||||
"VoipGroupPersonalAccount" = "personal account";
|
||||
@ -2888,3 +2901,10 @@
|
||||
"AiMessageEditorTo" = "To";
|
||||
"ButtonHelp" = "Help";
|
||||
"UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure.";
|
||||
"PollModalQuestionTitle" = "Question";
|
||||
"PollModalOptionsTitle" = "Options";
|
||||
"PollModalSettingsTitle" = "Settings";
|
||||
"PollModalAddMoreText_one" = "You can add {count} more option.";
|
||||
"PollModalAddMoreText_other" = "You can add {count} more options.";
|
||||
"PollModalAddNoMore" = "You have added the maximum number of options.";
|
||||
"PollDurationOther" = "Other";
|
||||
|
||||
@ -75,7 +75,7 @@ export { default as AiMessageEditorModal }
|
||||
from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal';
|
||||
|
||||
export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal';
|
||||
export { default as PollModal } from '../components/middle/composer/PollModal';
|
||||
export { default as PollModal } from '../components/modals/poll/PollModal';
|
||||
export { default as ToDoListModal } from '../components/middle/composer/ToDoListModal';
|
||||
export { default as SymbolMenu } from '../components/middle/composer/SymbolMenu';
|
||||
export { default as ChatCommandTooltip } from '../components/middle/composer/ChatCommandTooltip';
|
||||
|
||||
@ -382,6 +382,7 @@ const CalendarModal = ({
|
||||
onEnter={handleSubmit}
|
||||
dialogRef={dialogRef}
|
||||
hasAbsoluteCloseButton
|
||||
isNativeDialog
|
||||
>
|
||||
<div className="container">
|
||||
<div className="month-selector">
|
||||
|
||||
@ -19,7 +19,6 @@ import type {
|
||||
ApiMessage,
|
||||
ApiMessageEntity,
|
||||
ApiNewMediaTodo,
|
||||
ApiNewPoll,
|
||||
ApiPeer,
|
||||
ApiQuickReply,
|
||||
ApiReaction,
|
||||
@ -189,7 +188,6 @@ import EmojiTooltip from '../middle/composer/EmojiTooltip.async';
|
||||
import InlineBotTooltip from '../middle/composer/InlineBotTooltip.async';
|
||||
import MentionTooltip from '../middle/composer/MentionTooltip.async';
|
||||
import MessageInput from '../middle/composer/MessageInput';
|
||||
import PollModal from '../middle/composer/PollModal.async';
|
||||
import SendAsMenu from '../middle/composer/SendAsMenu.async';
|
||||
import StickerTooltip from '../middle/composer/StickerTooltip.async';
|
||||
import SymbolMenuButton from '../middle/composer/SymbolMenuButton';
|
||||
@ -253,7 +251,6 @@ type StateProps = {
|
||||
isReplying?: boolean;
|
||||
hasSuggestedPost?: boolean;
|
||||
forwardedMessagesCount?: number;
|
||||
pollModal: TabState['pollModal'];
|
||||
todoListModal: TabState['todoListModal'];
|
||||
aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult'];
|
||||
botKeyboardMessageId?: number;
|
||||
@ -321,7 +318,6 @@ type StateProps = {
|
||||
isAccountFrozen?: boolean;
|
||||
isAppConfigLoaded?: boolean;
|
||||
insertingPeerIdMention?: string;
|
||||
pollMaxAnswers?: number;
|
||||
replyToMessage?: ApiMessage;
|
||||
shouldOpenMessageMediaEditor?: TabState['shouldOpenMessageMediaEditor'];
|
||||
};
|
||||
@ -382,7 +378,6 @@ const Composer = ({
|
||||
isReplying,
|
||||
hasSuggestedPost,
|
||||
forwardedMessagesCount,
|
||||
pollModal,
|
||||
todoListModal,
|
||||
aiMessageEditorPendingResult,
|
||||
botKeyboardMessageId,
|
||||
@ -449,7 +444,6 @@ const Composer = ({
|
||||
isAccountFrozen,
|
||||
isAppConfigLoaded,
|
||||
insertingPeerIdMention,
|
||||
pollMaxAnswers,
|
||||
replyToMessage,
|
||||
shouldOpenMessageMediaEditor,
|
||||
onDropHide,
|
||||
@ -462,8 +456,6 @@ const Composer = ({
|
||||
clearDraft,
|
||||
saveDraft,
|
||||
showDialog,
|
||||
openPollModal,
|
||||
closePollModal,
|
||||
openTodoListModal,
|
||||
closeTodoListModal,
|
||||
openAiMessageEditorModal,
|
||||
@ -1654,31 +1646,6 @@ const Composer = ({
|
||||
});
|
||||
});
|
||||
|
||||
const handlePollSend = useLastCallback((poll: ApiNewPoll) => {
|
||||
if (!currentMessageList) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInScheduledList) {
|
||||
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
|
||||
handleActionWithPaymentConfirmation(
|
||||
handleMessageSchedule,
|
||||
{ poll },
|
||||
scheduledAt,
|
||||
scheduleRepeatPeriod,
|
||||
currentMessageList,
|
||||
);
|
||||
});
|
||||
closePollModal();
|
||||
} else {
|
||||
handleActionWithPaymentConfirmation(
|
||||
sendMessage,
|
||||
{ messageList: currentMessageList, poll, isSilent: isSilentPosting },
|
||||
);
|
||||
closePollModal();
|
||||
}
|
||||
});
|
||||
|
||||
const handleToDoListSend = useLastCallback((todo: ApiNewMediaTodo) => {
|
||||
if (!currentMessageList) {
|
||||
return;
|
||||
@ -2180,14 +2147,6 @@ const Composer = ({
|
||||
canScheduleUntilOnline={canScheduleUntilOnline && !isViewOnceEnabled}
|
||||
paidMessagesStars={paidMessagesStars}
|
||||
/>
|
||||
<PollModal
|
||||
isOpen={pollModal.isOpen}
|
||||
isQuiz={pollModal.isQuiz}
|
||||
shouldBeAnonymous={isChannel}
|
||||
maxOptionsCount={pollMaxAnswers}
|
||||
onClear={closePollModal}
|
||||
onSend={handlePollSend}
|
||||
/>
|
||||
<ToDoListModal
|
||||
modal={todoListModal}
|
||||
onClear={closeTodoListModal}
|
||||
@ -2505,7 +2464,6 @@ const Composer = ({
|
||||
canInsertDate={!isComposerBlocked}
|
||||
onFileSelect={handleFileSelect}
|
||||
onDateInsert={handleFormattedDateInsert}
|
||||
onPollCreate={openPollModal}
|
||||
onTodoListCreate={handleTodoListCreate}
|
||||
isScheduled={isInScheduledList}
|
||||
attachBots={isInMessageList ? attachBots : undefined}
|
||||
@ -2709,7 +2667,6 @@ export default memo(withGlobal<OwnProps>(
|
||||
(global, {
|
||||
chatId, threadId, storyId, messageListType, isMobile, type,
|
||||
}): Complete<StateProps> => {
|
||||
const appConfig = global.appConfig;
|
||||
const chat = selectChat(global, chatId);
|
||||
const chatBot = !isSystemBot(chatId) ? selectBot(global, chatId) : undefined;
|
||||
const isChatWithBot = Boolean(chatBot);
|
||||
@ -2824,7 +2781,6 @@ export default memo(withGlobal<OwnProps>(
|
||||
isReplying,
|
||||
hasSuggestedPost,
|
||||
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
|
||||
pollModal: tabState.pollModal,
|
||||
todoListModal: tabState.todoListModal,
|
||||
aiMessageEditorPendingResult: tabState.aiMessageEditorPendingResult,
|
||||
stickersForEmoji: global.stickers.forEmoji.stickers,
|
||||
@ -2883,7 +2839,9 @@ export default memo(withGlobal<OwnProps>(
|
||||
shouldPaidMessageAutoApprove,
|
||||
isSilentPosting,
|
||||
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen
|
||||
&& !tabState.aiMessageEditorModal,
|
||||
&& !tabState.aiMessageEditorModal
|
||||
&& !tabState.pollModal
|
||||
&& !tabState.sharePreparedMessageModal,
|
||||
starsBalance,
|
||||
isStarsBalanceModalOpen,
|
||||
shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton,
|
||||
@ -2891,7 +2849,6 @@ export default memo(withGlobal<OwnProps>(
|
||||
isAccountFrozen,
|
||||
isAppConfigLoaded,
|
||||
insertingPeerIdMention,
|
||||
pollMaxAnswers: appConfig.pollMaxAnswers,
|
||||
shouldOpenMessageMediaEditor,
|
||||
replyToMessage,
|
||||
};
|
||||
|
||||
@ -1,89 +1,27 @@
|
||||
@layer ui.layout {
|
||||
.control {
|
||||
--control-grid-template-areas: "input before label after";
|
||||
--control-grid-columns: auto auto 1fr auto;
|
||||
--control-gap: 1rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas: "input label";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas: var(--control-grid-template-areas);
|
||||
grid-template-columns: var(--control-grid-columns);
|
||||
flex-grow: 1;
|
||||
column-gap: 1rem;
|
||||
column-gap: 0;
|
||||
align-items: center;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input label" "input desc";
|
||||
}
|
||||
|
||||
&:has(> .controlAfter) {
|
||||
grid-template-areas: "input label after";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input label after" "input desc after";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore) {
|
||||
grid-template-areas: "input before label";
|
||||
grid-template-columns: auto auto 1fr;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input before label" "input before desc";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore):has(> .controlAfter) {
|
||||
grid-template-areas: "input before label after";
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "input before label after" "input before desc after";
|
||||
}
|
||||
}
|
||||
|
||||
// --- inputEnd: input at end ---
|
||||
&.inputEnd {
|
||||
grid-template-areas: "label input";
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "label input" "desc input";
|
||||
}
|
||||
|
||||
&:has(> .controlAfter) {
|
||||
grid-template-areas: "label after input";
|
||||
grid-template-columns: 1fr auto auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "label after input" "desc after input";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore) {
|
||||
grid-template-areas: "before label input";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "before label input" "before desc input";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .controlBefore):has(> .controlAfter) {
|
||||
grid-template-areas: "before label after input";
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
|
||||
&:has(> .controlDescription) {
|
||||
grid-template-areas: "before label after input" "before desc after input";
|
||||
}
|
||||
}
|
||||
--control-grid-template-areas: "before label after input";
|
||||
--control-grid-columns: auto 1fr auto auto;
|
||||
}
|
||||
|
||||
&:has(> .controlDescription) > .controlLabel {
|
||||
align-self: end;
|
||||
}
|
||||
&:has(> .controlDescription) {
|
||||
--control-grid-template-areas: "input before label after" "input before desc after";
|
||||
|
||||
&:has(> .controlDescription) > .input,
|
||||
&:has(> .controlDescription) > .spinner {
|
||||
transform: translateY(50%);
|
||||
grid-row: 1;
|
||||
align-self: end;
|
||||
&.inputEnd {
|
||||
--control-grid-template-areas: "before label after input" "before desc after input";
|
||||
}
|
||||
}
|
||||
|
||||
:global(label) {
|
||||
@ -94,6 +32,12 @@
|
||||
.input {
|
||||
grid-area: input;
|
||||
align-self: center;
|
||||
margin-inline-end: var(--control-gap);
|
||||
|
||||
.inputEnd > & {
|
||||
margin-inline-start: var(--control-gap);
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@ -101,6 +45,12 @@
|
||||
|
||||
grid-area: input;
|
||||
align-self: center;
|
||||
margin-inline-end: var(--control-gap);
|
||||
|
||||
.inputEnd > & {
|
||||
margin-inline-start: var(--control-gap);
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.controlLabel {
|
||||
@ -130,10 +80,38 @@
|
||||
.controlBefore {
|
||||
grid-area: before;
|
||||
align-self: center;
|
||||
margin-inline-end: var(--control-gap);
|
||||
}
|
||||
|
||||
.controlAfter {
|
||||
grid-area: after;
|
||||
align-self: center;
|
||||
margin-inline-start: var(--control-gap);
|
||||
}
|
||||
|
||||
.controlIcon {
|
||||
--control-icon-background-color: transparent;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: var(--control-icon-background-color);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.controlIconFilled {
|
||||
color: white;
|
||||
background-image: linear-gradient(180deg, rgb(255 255 255 / 30%) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
.controlIconGlyph {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import type { TeactNode } from '../../../lib/teact/teact';
|
||||
import { createContext, memo, useMemo } from '../../../lib/teact/teact';
|
||||
|
||||
import type { IconName } from '../../../types/icons';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useContext from '../../../hooks/data/useContext';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useUniqueId from '../../../hooks/useUniqueId';
|
||||
|
||||
import Icon from '../../common/icons/Icon';
|
||||
import Spinner from '../../ui/Spinner';
|
||||
import { useInteractiveContext } from './Interactive';
|
||||
|
||||
@ -123,6 +126,12 @@ type ControlSlotProps = {
|
||||
children: TeactNode;
|
||||
};
|
||||
|
||||
type ControlIconProps = {
|
||||
iconName?: IconName;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
};
|
||||
|
||||
const ControlBefore = ({ className, children }: ControlSlotProps) => {
|
||||
return (
|
||||
<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
|
||||
|
||||
export default memo(Control);
|
||||
@ -147,4 +172,5 @@ export {
|
||||
ControlDescription,
|
||||
ControlBefore,
|
||||
ControlAfter,
|
||||
ControlIcon,
|
||||
};
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
|
||||
@ -17,6 +17,18 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
|
||||
padding: 0 1rem;
|
||||
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
@ -35,4 +47,13 @@
|
||||
.description + .island {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.title + .island {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.description + .title,
|
||||
.island + .title {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import type { ElementRef } from '../../../lib/teact/teact';
|
||||
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import styles from './Island.module.scss';
|
||||
|
||||
type OwnProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: React.ReactNode;
|
||||
ref?: ElementRef<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const Island = ({ className, children, ...otherProps }: OwnProps) => {
|
||||
const Island = ({ ref, className, children, ...otherProps }: OwnProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={buildClassName(styles.island, className)}
|
||||
{...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) => {
|
||||
return (
|
||||
<div
|
||||
@ -42,5 +57,6 @@ const IslandText = ({ className, children, ...otherProps }: OwnProps) => {
|
||||
export default Island;
|
||||
export {
|
||||
IslandDescription,
|
||||
IslandTitle,
|
||||
IslandText,
|
||||
};
|
||||
|
||||
281
src/components/gili/modal/Modal.module.scss
Normal file
281
src/components/gili/modal/Modal.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
477
src/components/gili/modal/Modal.tsx
Normal file
477
src/components/gili/modal/Modal.tsx
Normal 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,
|
||||
};
|
||||
@ -2,79 +2,119 @@
|
||||
.root {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
width: 1.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
cursor: inherit;
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.track {
|
||||
--switch-track-color: var(--ui-border-color, var(--color-borders-input));
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.625rem;
|
||||
|
||||
appearance: none;
|
||||
background-color: var(--ui-border-color, var(--color-borders-input));
|
||||
background-color: var(--switch-track-color);
|
||||
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
.thumb {
|
||||
transform: translateX(-0.125rem);
|
||||
|
||||
transform: translateX(-0.125rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
display: block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--switch-track-color);
|
||||
border-radius: 50%;
|
||||
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--ui-border-color, var(--color-borders-input));
|
||||
border-radius: 50%;
|
||||
color: var(--switch-track-color);
|
||||
|
||||
background-color: var(--ui-bg-color, var(--color-background));
|
||||
background-color: var(--ui-bg-color, var(--color-background));
|
||||
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border-color: var(--ui-accent-color, var(--color-primary));
|
||||
background-color: var(--ui-accent-color, var(--color-primary));
|
||||
.input {
|
||||
&:checked + .track {
|
||||
--switch-track-color: var(--ui-accent-color, var(--color-primary));
|
||||
|
||||
&::before {
|
||||
.thumb {
|
||||
transform: translateX(0.75rem);
|
||||
border-color: var(--ui-accent-color, var(--color-primary));
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: var(--input-disabled-opacity, 0.5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:focus-visible + .track {
|
||||
outline: 2px solid var(--ui-accent-color, var(--color-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
cursor: default;
|
||||
opacity: var(--input-disabled-opacity, 0.5);
|
||||
}
|
||||
|
||||
.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lockIconTransition {
|
||||
transform: scale(0.5);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:global(.open) {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.permissionColors {
|
||||
background-color: var(--color-error);
|
||||
|
||||
&::before {
|
||||
border-color: var(--color-error);
|
||||
.track {
|
||||
--switch-track-color: var(--color-error);
|
||||
}
|
||||
|
||||
&:checked {
|
||||
border-color: var(--color-green);
|
||||
background-color: var(--color-green);
|
||||
|
||||
&::before {
|
||||
border-color: var(--color-green);
|
||||
}
|
||||
.input:checked + .track {
|
||||
--switch-track-color: var(--color-green);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
.input:focus-visible + .track {
|
||||
outline-color: var(--color-error);
|
||||
}
|
||||
|
||||
&:checked:focus-visible {
|
||||
.input:checked:focus-visible + .track {
|
||||
outline-color: var(--color-green);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import buildClassName from '../../../util/buildClassName';
|
||||
|
||||
import useLastCallback from '../../../hooks/useLastCallback';
|
||||
|
||||
import Icon from '../../common/icons/Icon';
|
||||
import ShowTransition from '../../ui/ShowTransition';
|
||||
import { useControlContext } from '../layout/Control';
|
||||
import { useInteractiveContext } from '../layout/Interactive';
|
||||
|
||||
@ -17,6 +19,7 @@ type InputProps = React.DetailedHTMLProps<
|
||||
type OwnProps = {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
locked?: boolean;
|
||||
withPermissionColors?: boolean;
|
||||
className?: string;
|
||||
onChange?: (checked: boolean) => void;
|
||||
@ -27,6 +30,7 @@ type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
|
||||
const Switch = ({
|
||||
checked,
|
||||
disabled,
|
||||
locked,
|
||||
withPermissionColors,
|
||||
className,
|
||||
onChange,
|
||||
@ -40,27 +44,42 @@ const Switch = ({
|
||||
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
|
||||
|
||||
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (locked) return;
|
||||
|
||||
onChange?.(e.currentTarget.checked);
|
||||
});
|
||||
|
||||
if (interactive?.isLoading) return undefined;
|
||||
|
||||
return (
|
||||
<input
|
||||
{...restProps}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id={resolvedId}
|
||||
checked={checked}
|
||||
disabled={isDisabled}
|
||||
<span
|
||||
className={buildClassName(
|
||||
styles.root,
|
||||
isDisabled && styles.disabled,
|
||||
locked && styles.locked,
|
||||
withPermissionColors && styles.permissionColors,
|
||||
control?.inputClassName,
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -226,7 +226,10 @@ const MediaViewer = ({
|
||||
const prevOrigin = usePrevious(origin);
|
||||
const prevItem = usePrevious(currentItem);
|
||||
const prevBestImageData = usePrevious(bestImageData);
|
||||
const textParts = message ? renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }) : undefined;
|
||||
const textMessage = currentItem?.type === 'message' ? currentItem.message : undefined;
|
||||
const textParts = textMessage
|
||||
? renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true })
|
||||
: undefined;
|
||||
const hasFooter = Boolean(textParts);
|
||||
|
||||
useEffectWithPrevDeps(([prevIsOpen, prevIsHidden]) => {
|
||||
|
||||
@ -57,12 +57,11 @@ export type OwnProps = {
|
||||
theme: ThemeKey;
|
||||
canEditMedia?: boolean;
|
||||
editingMessage?: ApiMessage;
|
||||
messageListType?: MessageListType;
|
||||
messageListType: MessageListType;
|
||||
paidMessagesStars?: number;
|
||||
canInsertDate?: boolean;
|
||||
onFileSelect: (files: File[]) => void;
|
||||
onDateInsert: (text: ApiFormattedText) => void;
|
||||
onPollCreate: NoneToVoidFunction;
|
||||
onTodoListCreate: NoneToVoidFunction;
|
||||
onMenuOpen: NoneToVoidFunction;
|
||||
onMenuClose: NoneToVoidFunction;
|
||||
@ -93,10 +92,10 @@ const AttachMenu = ({
|
||||
onDateInsert,
|
||||
onMenuOpen,
|
||||
onMenuClose,
|
||||
onPollCreate,
|
||||
onTodoListCreate,
|
||||
}: OwnProps) => {
|
||||
const {
|
||||
openPollModal,
|
||||
updateAttachmentSettings,
|
||||
} = getActions();
|
||||
const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag();
|
||||
@ -190,6 +189,11 @@ const AttachMenu = ({
|
||||
openDateModal();
|
||||
});
|
||||
|
||||
const handlePollCreate = useLastCallback(() => {
|
||||
closeAttachMenu();
|
||||
openPollModal({ chatId, threadId, messageListType });
|
||||
});
|
||||
|
||||
if (!isButtonVisible && !isDateModalOpen) {
|
||||
return undefined;
|
||||
}
|
||||
@ -275,7 +279,7 @@ const AttachMenu = ({
|
||||
</>
|
||||
)}
|
||||
{canAttachPolls && !editingMessage && (
|
||||
<MenuItem icon="poll" onClick={onPollCreate}>{oldLang('Poll')}</MenuItem>
|
||||
<MenuItem icon="poll" onClick={handlePollCreate}>{lang('Poll')}</MenuItem>
|
||||
)}
|
||||
{canAttachToDoLists && !editingMessage && (
|
||||
<MenuItem icon="select" onClick={onTodoListCreate}>{lang('TitleToDoList')}</MenuItem>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -62,6 +62,7 @@
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
column-gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.question {
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from '../../../../lib/teact/teact';
|
||||
import { getActions, getGlobal } from '../../../../global';
|
||||
import { getActions, getGlobal, getPromiseActions, withGlobal } from '../../../../global';
|
||||
|
||||
import type {
|
||||
ApiFormattedText,
|
||||
@ -34,6 +34,7 @@ import useLastCallback from '../../../../hooks/useLastCallback';
|
||||
import AvatarList from '../../../common/AvatarList';
|
||||
import CompactMapPreview from '../../../common/CompactMapPreview';
|
||||
import Document from '../../../common/Document';
|
||||
import Icon from '../../../common/icons/Icon';
|
||||
import PeerColorWrapper from '../../../common/PeerColorWrapper';
|
||||
import StickerView from '../../../common/StickerView';
|
||||
import Button from '../../../ui/Button';
|
||||
@ -44,6 +45,7 @@ import Video from '../Video';
|
||||
import PollOption from './PollOption';
|
||||
|
||||
import styles from './Poll.module.scss';
|
||||
import optionStyles from './PollOption.module.scss';
|
||||
|
||||
type OwnProps = {
|
||||
chatId: string;
|
||||
@ -56,11 +58,16 @@ type OwnProps = {
|
||||
observeIntersectionForPlaying?: ObserveFn;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
pollMaxAnswers: number;
|
||||
};
|
||||
|
||||
const ATTACHED_MAP_WIDTH = 350;
|
||||
const ATTACHED_MAP_HEIGHT = 200;
|
||||
const ATTACHED_MAP_ZOOM = 15;
|
||||
const STICKER_PREVIEW_SIZE = 96;
|
||||
const VOTE_TIMEOUT = 5000;
|
||||
const MAX_OPTION_LENGTH = 100;
|
||||
|
||||
const Poll = ({
|
||||
chatId,
|
||||
@ -71,7 +78,8 @@ const Poll = ({
|
||||
isInScheduled,
|
||||
observeIntersectionForLoading,
|
||||
observeIntersectionForPlaying,
|
||||
}: OwnProps) => {
|
||||
pollMaxAnswers,
|
||||
}: OwnProps & StateProps) => {
|
||||
const {
|
||||
openMapModal,
|
||||
openMediaViewer,
|
||||
@ -80,6 +88,7 @@ const Poll = ({
|
||||
sendPollVote,
|
||||
loadMessage,
|
||||
} = getActions();
|
||||
const { appendPollAnswer } = getPromiseActions();
|
||||
const lang = useLang();
|
||||
const serverTime = getServerTime();
|
||||
|
||||
@ -88,6 +97,8 @@ const Poll = ({
|
||||
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
|
||||
const [isExplanationOpen, setIsExplanationOpen] = useState(false);
|
||||
const [isSendingVote, setIsSendingVote] = useState(false);
|
||||
const [isAppendingAnswer, setIsAppendingAnswer] = useState(false);
|
||||
const [newAnswerText, setNewAnswerText] = useState('');
|
||||
const [isViewingAuthorResults, setIsViewingAuthorResults] = useState(false);
|
||||
const [answerOrder] = useState<string[]>(() => (
|
||||
buildAnswerOrder(answers, summary.shouldShuffleAnswers)
|
||||
@ -118,6 +129,11 @@ const Poll = ({
|
||||
(!canVote && !areResultsHiddenForCurrentUser) || isViewingAuthorResults
|
||||
);
|
||||
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(() => {
|
||||
if (!canVote) {
|
||||
@ -162,10 +178,19 @@ const Poll = ({
|
||||
}, [canToggleAuthorResults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasExplanation) {
|
||||
if (!canShowExplanation) {
|
||||
setIsExplanationOpen(false);
|
||||
}
|
||||
}, [hasExplanation]);
|
||||
}, [canShowExplanation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canAppendAnswer) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNewAnswerText('');
|
||||
setIsAppendingAnswer(false);
|
||||
}, [canAppendAnswer]);
|
||||
|
||||
const answersByOption = useMemo(() => buildCollectionByKey(answers, 'option'), [answers]);
|
||||
|
||||
@ -261,6 +286,37 @@ const Poll = ({
|
||||
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) => {
|
||||
if (!standaloneMedia.length) {
|
||||
return;
|
||||
@ -372,6 +428,23 @@ const Poll = ({
|
||||
</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) {
|
||||
return (
|
||||
<Button
|
||||
@ -479,12 +552,16 @@ const Poll = ({
|
||||
canShowResultsPanel,
|
||||
canToggleAuthorResults,
|
||||
canVote,
|
||||
canAppendAnswer,
|
||||
handleAppendAnswer,
|
||||
isAppendingAnswer,
|
||||
isMultipleChoice,
|
||||
isSendingVote,
|
||||
isViewingAuthorResults,
|
||||
lang,
|
||||
selectedOptions.length,
|
||||
summary.isQuiz,
|
||||
trimmedNewAnswerText,
|
||||
footerSubtext,
|
||||
totalVoters,
|
||||
]);
|
||||
@ -493,7 +570,7 @@ const Poll = ({
|
||||
<>
|
||||
{attachedMediaEl}
|
||||
<div className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}>
|
||||
{isExplanationOpen && hasExplanation && (
|
||||
{isExplanationOpen && canShowExplanation && (
|
||||
<PeerColorWrapper className={styles.explanation} shouldReset>
|
||||
<div className={styles.explanationHeader}>
|
||||
<span className={styles.explanationTitle}>
|
||||
@ -531,7 +608,7 @@ const Poll = ({
|
||||
<div className={styles.question} dir="auto">
|
||||
{questionText}
|
||||
</div>
|
||||
{hasExplanation && !isExplanationOpen && (
|
||||
{canShowExplanation && !isExplanationOpen && (
|
||||
<div className={styles.explanationToggleButton}>
|
||||
<Button
|
||||
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>
|
||||
{footerContent && (
|
||||
<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));
|
||||
|
||||
@ -29,6 +29,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: var(--custom-cursor, pointer);
|
||||
}
|
||||
|
||||
.hasMediaColumn {
|
||||
--poll-option-media-width: 3rem;
|
||||
}
|
||||
@ -48,6 +52,31 @@
|
||||
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-size: 1.25rem;
|
||||
}
|
||||
@ -79,6 +108,15 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.addAnswer {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.addAnswerContent {
|
||||
grid-column: 2 / 4;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.sideMeta {
|
||||
display: flex;
|
||||
grid-column: 3;
|
||||
|
||||
@ -314,8 +314,9 @@ const PollOption = ({
|
||||
hasResults && !hasMaskedResults && styles.hasResults,
|
||||
shouldReserveMediaEndColumn && styles.hasMediaColumn,
|
||||
hasResults && !hasMaskedResults && isQuiz && !result?.isCorrect && styles.incorrect,
|
||||
!hasResults && !isInScheduled && styles.clickable,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onClick={!isInScheduled ? handleClick : undefined}
|
||||
>
|
||||
<Transition
|
||||
name="fade"
|
||||
|
||||
@ -3,9 +3,12 @@ import { withGlobal } from '../../global';
|
||||
|
||||
import type { TabState } from '../../global/types';
|
||||
|
||||
import { selectTabState } from '../../global/selectors';
|
||||
import { selectCanAnimateInterface, selectTabState } from '../../global/selectors';
|
||||
import { pick } from '../../util/iteratees';
|
||||
|
||||
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
|
||||
import useShowTransition from '../../hooks/useShowTransition';
|
||||
|
||||
import VerificationMonetizationModal from '../common/VerificationMonetizationModal.async';
|
||||
import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal.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 PaidReactionModal from './paidReaction/PaidReactionModal.async';
|
||||
import PasskeyModal from './passkey/PasskeyModal.async';
|
||||
import PollModal from './poll/PollModal.async';
|
||||
import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async';
|
||||
import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async';
|
||||
import ProfileRatingModal from './profileRating/ProfileRatingModal.async';
|
||||
@ -97,6 +101,7 @@ type ModalKey = keyof Pick<TabState,
|
||||
'starsPayment' |
|
||||
'starsTransactionModal' |
|
||||
'paidReactionModal' |
|
||||
'pollModal' |
|
||||
'suggestMessageModal' |
|
||||
'suggestedPostApprovalModal' |
|
||||
'webApps' |
|
||||
@ -154,20 +159,68 @@ type ModalKey = keyof Pick<TabState,
|
||||
'editRankModal' |
|
||||
'rankModal'
|
||||
>;
|
||||
type WrappedModalKey = 'pollModal';
|
||||
type LegacyModalKey = Exclude<ModalKey, WrappedModalKey>;
|
||||
|
||||
type StateProps = {
|
||||
type ModalStateProps = {
|
||||
[K in ModalKey]?: TabState[K];
|
||||
};
|
||||
type ModalRegistry = {
|
||||
[K in ModalKey]: FC<{
|
||||
type StateProps = ModalStateProps & {
|
||||
shouldAnimateInterface: boolean;
|
||||
};
|
||||
type LegacyModalRegistry = {
|
||||
[K in LegacyModalKey]: FC<{
|
||||
modal: TabState[K];
|
||||
}>;
|
||||
};
|
||||
type WrappedModalRegistry = {
|
||||
[K in WrappedModalKey]: FC<{
|
||||
modal: NonNullable<TabState[K]>;
|
||||
isOpen: boolean;
|
||||
}>;
|
||||
};
|
||||
type Entries<T> = {
|
||||
[K in keyof T]: [K, T[K]];
|
||||
}[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,
|
||||
giftCodeModal: GiftCodeModal,
|
||||
boostModal: BoostModal,
|
||||
@ -241,18 +294,41 @@ const MODALS: ModalRegistry = {
|
||||
editRankModal: EditRankModal,
|
||||
rankModal: RankModal,
|
||||
};
|
||||
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
|
||||
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>;
|
||||
const WRAPPED_MODALS: WrappedModalRegistry = {
|
||||
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) => {
|
||||
return MODAL_ENTRIES.map(([key, ModalComponent]) => (
|
||||
// @ts-ignore -- TS does not preserve tuple types in `map` callbacks
|
||||
<ModalComponent key={key} modal={modalProps[key]} />
|
||||
));
|
||||
const { shouldAnimateInterface } = modalProps;
|
||||
|
||||
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(
|
||||
(global): Complete<StateProps> => (
|
||||
pick(selectTabState(global), MODAL_KEYS) as Complete<StateProps>
|
||||
),
|
||||
(global): Complete<StateProps> => ({
|
||||
...(pick(selectTabState(global), MODAL_KEYS) as Complete<ModalStateProps>),
|
||||
shouldAnimateInterface: selectCanAnimateInterface(global),
|
||||
}),
|
||||
)(ModalContainer));
|
||||
|
||||
141
src/components/modals/poll/PollModal.module.scss
Normal file
141
src/components/modals/poll/PollModal.module.scss
Normal 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;
|
||||
}
|
||||
949
src/components/modals/poll/PollModal.tsx
Normal file
949
src/components/modals/poll/PollModal.tsx
Normal 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));
|
||||
@ -66,6 +66,7 @@ const ConfirmDialog: FC<OwnProps> = ({
|
||||
header={header}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
isNativeDialog
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
>
|
||||
{text && text.split('\\n').map((textPart) => (
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
z-index: var(--z-modal);
|
||||
color: var(--color-text);
|
||||
|
||||
&[open] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.confirm,
|
||||
&.pin {
|
||||
z-index: var(--z-modal-confirm);
|
||||
@ -295,4 +299,23 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { ElementRef, TeactNode } 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';
|
||||
|
||||
@ -41,6 +43,7 @@ export type OwnProps = {
|
||||
isBackButton?: boolean;
|
||||
noBackdrop?: boolean;
|
||||
noBackdropClose?: boolean;
|
||||
isNativeDialog?: boolean;
|
||||
children: React.ReactNode;
|
||||
style?: string;
|
||||
dialogStyle?: string;
|
||||
@ -64,6 +67,7 @@ const Modal = (props: OwnProps) => {
|
||||
isOpen,
|
||||
noBackdropClose,
|
||||
noFreezeOnClose,
|
||||
isNativeDialog,
|
||||
onClose,
|
||||
onCloseAnimationEnd,
|
||||
onEnter,
|
||||
@ -72,7 +76,7 @@ const Modal = (props: OwnProps) => {
|
||||
const {
|
||||
ref: modalRef,
|
||||
shouldRender,
|
||||
} = useShowTransition({
|
||||
} = useShowTransition<HTMLElement>({
|
||||
isOpen,
|
||||
withShouldRender: true,
|
||||
onCloseAnimationEnd,
|
||||
@ -117,6 +121,8 @@ const Modal = (props: OwnProps) => {
|
||||
} = useContextMenuHandlers(moreButtonRef);
|
||||
|
||||
const actualDialogRef = dialogRef || localDialogRef;
|
||||
const divModalRef = modalRef as ElementRef<HTMLDivElement>;
|
||||
const nativeDialogRef = modalRef as ElementRef<HTMLDialogElement>;
|
||||
|
||||
const getRootElement = useLastCallback(() => actualDialogRef.current);
|
||||
const getTriggerElement = useLastCallback(() => moreButtonRef.current);
|
||||
@ -150,6 +156,52 @@ const Modal = (props: OwnProps) => {
|
||||
), [isOpen, onClose, handleEnter]);
|
||||
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({
|
||||
isActive: isOpen,
|
||||
onBack: onClose,
|
||||
@ -218,14 +270,9 @@ const Modal = (props: OwnProps) => {
|
||||
dialogClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={fullClassName}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
>
|
||||
function renderContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="modal-container">
|
||||
<div className="modal-backdrop" onClick={!noBackdropClose ? onClose : undefined} />
|
||||
{withBalanceBar && (
|
||||
@ -274,7 +321,30 @@ const Modal = (props: OwnProps) => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -126,9 +126,16 @@ addActionHandler('clickBotInlineButton', (global, actions, payload): ActionRetur
|
||||
break;
|
||||
}
|
||||
|
||||
case 'requestPoll':
|
||||
actions.openPollModal({ isQuiz: button.isQuiz, tabId });
|
||||
case 'requestPoll': {
|
||||
actions.openPollModal({
|
||||
chatId,
|
||||
threadId,
|
||||
messageListType: 'thread',
|
||||
isQuiz: button.isQuiz,
|
||||
tabId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'requestPhone': {
|
||||
const user = global.currentUserId ? selectUser(global, global.currentUserId) : undefined;
|
||||
|
||||
@ -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 => {
|
||||
const { chatId, messageId, completedIds, incompletedIds } = payload;
|
||||
const chat = selectChat(global, chatId);
|
||||
|
||||
@ -754,25 +754,24 @@ addActionHandler('exitMessageSelectMode', (global, actions, payload): ActionRetu
|
||||
});
|
||||
|
||||
addActionHandler('openPollModal', (global, actions, payload): ActionReturnType => {
|
||||
const { isQuiz, tabId = getCurrentTabId() } = payload || {};
|
||||
const {
|
||||
chatId,
|
||||
threadId,
|
||||
messageListType,
|
||||
isQuiz,
|
||||
tabId = getCurrentTabId(),
|
||||
} = payload;
|
||||
|
||||
return updateTabState(global, {
|
||||
pollModal: {
|
||||
isOpen: true,
|
||||
chatId,
|
||||
threadId,
|
||||
messageListType,
|
||||
isQuiz,
|
||||
},
|
||||
}, tabId);
|
||||
});
|
||||
|
||||
addActionHandler('closePollModal', (global, actions, payload): ActionReturnType => {
|
||||
const { tabId = getCurrentTabId() } = payload || {};
|
||||
|
||||
return updateTabState(global, {
|
||||
pollModal: {
|
||||
isOpen: false,
|
||||
},
|
||||
}, tabId);
|
||||
});
|
||||
addTabStateResetterAction('closePollModal', 'pollModal');
|
||||
|
||||
addActionHandler('openTodoListModal', (global, actions, payload): ActionReturnType => {
|
||||
const {
|
||||
|
||||
@ -464,10 +464,6 @@ export const INITIAL_TAB_STATE: TabState = {
|
||||
byChatId: {},
|
||||
},
|
||||
|
||||
pollModal: {
|
||||
isOpen: false,
|
||||
},
|
||||
|
||||
requestedTranslations: {
|
||||
byChatId: {},
|
||||
},
|
||||
|
||||
@ -1528,6 +1528,11 @@ export interface ActionPayloads {
|
||||
messageId: number;
|
||||
options: string[];
|
||||
};
|
||||
appendPollAnswer: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
text: string;
|
||||
};
|
||||
toggleTodoCompleted: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
@ -2421,9 +2426,12 @@ export interface ActionPayloads {
|
||||
refreshLangPackFromCache: {
|
||||
langCode: string;
|
||||
};
|
||||
openPollModal: ({
|
||||
openPollModal: {
|
||||
chatId: string;
|
||||
threadId?: ThreadId;
|
||||
messageListType: MessageListType;
|
||||
isQuiz?: boolean;
|
||||
} & WithTabId) | undefined;
|
||||
} & WithTabId;
|
||||
closePollModal: WithTabId | undefined;
|
||||
openTodoListModal: {
|
||||
chatId: string;
|
||||
|
||||
@ -82,6 +82,7 @@ import type {
|
||||
MediaViewerMedia,
|
||||
MediaViewerOrigin,
|
||||
MessageList,
|
||||
MessageListType,
|
||||
MiddleSearchParams,
|
||||
NewChatMembersProgress,
|
||||
PaymentStep,
|
||||
@ -567,8 +568,10 @@ export type TabState = {
|
||||
filter?: ApiChatType[];
|
||||
};
|
||||
|
||||
pollModal: {
|
||||
isOpen: boolean;
|
||||
pollModal?: {
|
||||
chatId: string;
|
||||
threadId?: ThreadId;
|
||||
messageListType: MessageListType;
|
||||
isQuiz?: boolean;
|
||||
};
|
||||
|
||||
|
||||
545
src/hooks/useReorderableList.ts
Normal file
545
src/hooks/useReorderableList.ts
Normal 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);
|
||||
}
|
||||
@ -1812,6 +1812,7 @@ messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer r
|
||||
messages.declineUrlAuth#35436bbc url: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.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.readPollVotes#1720b4d8 flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory;
|
||||
updates.getState#edd4882a = updates.State;
|
||||
|
||||
@ -250,6 +250,7 @@
|
||||
"messages.deleteTopicHistory",
|
||||
"messages.toggleTodoCompleted",
|
||||
"messages.appendTodoList",
|
||||
"messages.addPollAnswer",
|
||||
"messages.summarizeText",
|
||||
"messages.editChatCreator",
|
||||
"messages.getFutureChatCreatorAfterLeave",
|
||||
|
||||
@ -484,7 +484,7 @@ function renderChildren(
|
||||
forceMoveToEnd = false,
|
||||
namespace?: string,
|
||||
) {
|
||||
if (('props' in $new) && $new.props.teactFastList) {
|
||||
if ($new.type === VirtualType.Tag && $new.props.teactFastList) {
|
||||
renderFastListChildren($current, $new, currentContext, currentEl, namespace);
|
||||
return;
|
||||
}
|
||||
@ -965,7 +965,7 @@ function getChildKeysByIndex(children: VirtualElementChildren) {
|
||||
if (isNullable(key)) {
|
||||
if (DEBUG && isParentElement($child)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Missing `key` in `teactFastList`');
|
||||
console.warn('Missing `key` in `teactFastList`', $child);
|
||||
}
|
||||
|
||||
key = `${INDEX_KEY_PREFIX}${index}`;
|
||||
|
||||
@ -130,6 +130,8 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
|
||||
starsSuggestedPostFutureMin: 300,
|
||||
starsSuggestedPostFutureMax: 2678400,
|
||||
starsSuggestedPostCommissionPermille: 850,
|
||||
pollMaxAnswers: 12,
|
||||
pollClosePeriodMax: 2628000,
|
||||
noForwardsRequestExpirePeriod: 86400,
|
||||
tonSuggestedPostCommissionPermille: 850,
|
||||
todoItemLengthMax: 64,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -74,282 +74,286 @@ $icons-map: (
|
||||
"chats-badge": "\f138",
|
||||
"check": "\f139",
|
||||
"check-bold": "\f13a",
|
||||
"clock": "\f13b",
|
||||
"clock-edit": "\f13c",
|
||||
"close": "\f13d",
|
||||
"close-circle": "\f13e",
|
||||
"close-topic": "\f13f",
|
||||
"closed-gift": "\f140",
|
||||
"cloud-download": "\f141",
|
||||
"collapse": "\f142",
|
||||
"collapse-modal": "\f143",
|
||||
"colorize": "\f144",
|
||||
"combine-craft": "\f145",
|
||||
"comments": "\f146",
|
||||
"comments-sticker": "\f147",
|
||||
"copy": "\f148",
|
||||
"copy-media": "\f149",
|
||||
"craft": "\f14a",
|
||||
"crop": "\f14b",
|
||||
"crown-take-off": "\f14c",
|
||||
"crown-take-off-outline": "\f14d",
|
||||
"crown-wear": "\f14e",
|
||||
"crown-wear-outline": "\f14f",
|
||||
"darkmode": "\f150",
|
||||
"data": "\f151",
|
||||
"delete": "\f152",
|
||||
"delete-filled": "\f153",
|
||||
"delete-left": "\f154",
|
||||
"delete-user": "\f155",
|
||||
"diamond": "\f156",
|
||||
"document": "\f157",
|
||||
"double-badge": "\f158",
|
||||
"down": "\f159",
|
||||
"download": "\f15a",
|
||||
"dropdown-arrows": "\f15b",
|
||||
"eats": "\f15c",
|
||||
"edit": "\f15d",
|
||||
"email": "\f15e",
|
||||
"enter": "\f15f",
|
||||
"expand": "\f160",
|
||||
"expand-modal": "\f161",
|
||||
"eye": "\f162",
|
||||
"eye-crossed": "\f163",
|
||||
"eye-crossed-outline": "\f164",
|
||||
"eye-outline": "\f165",
|
||||
"favorite": "\f166",
|
||||
"favorite-filled": "\f167",
|
||||
"file-badge": "\f168",
|
||||
"flag": "\f169",
|
||||
"flip": "\f16a",
|
||||
"folder": "\f16b",
|
||||
"folder-badge": "\f16c",
|
||||
"folder-tabs-bot": "\f16d",
|
||||
"folder-tabs-channel": "\f16e",
|
||||
"folder-tabs-chat": "\f16f",
|
||||
"folder-tabs-chats": "\f170",
|
||||
"folder-tabs-folder": "\f171",
|
||||
"folder-tabs-group": "\f172",
|
||||
"folder-tabs-star": "\f173",
|
||||
"folder-tabs-user": "\f174",
|
||||
"fontsize": "\f175",
|
||||
"forums": "\f176",
|
||||
"forward": "\f177",
|
||||
"fragment": "\f178",
|
||||
"frozen-time": "\f179",
|
||||
"fullscreen": "\f17a",
|
||||
"gifs": "\f17b",
|
||||
"gift": "\f17c",
|
||||
"gift-transfer-inline": "\f17d",
|
||||
"group": "\f17e",
|
||||
"group-filled": "\f17f",
|
||||
"grouped": "\f180",
|
||||
"grouped-disable": "\f181",
|
||||
"hand-stop": "\f182",
|
||||
"hand-stop-filled": "\f183",
|
||||
"hashtag": "\f184",
|
||||
"hd-photo": "\f185",
|
||||
"heart": "\f186",
|
||||
"heart-outline": "\f187",
|
||||
"help": "\f188",
|
||||
"info": "\f189",
|
||||
"info-filled": "\f18a",
|
||||
"install": "\f18b",
|
||||
"italic": "\f18c",
|
||||
"key": "\f18d",
|
||||
"keyboard": "\f18e",
|
||||
"lamp": "\f18f",
|
||||
"language": "\f190",
|
||||
"large-pause": "\f191",
|
||||
"large-play": "\f192",
|
||||
"link": "\f193",
|
||||
"link-badge": "\f194",
|
||||
"link-broken": "\f195",
|
||||
"location": "\f196",
|
||||
"lock": "\f197",
|
||||
"lock-badge": "\f198",
|
||||
"logout": "\f199",
|
||||
"loop": "\f19a",
|
||||
"mention": "\f19b",
|
||||
"menu": "\f19c",
|
||||
"message": "\f19d",
|
||||
"message-failed": "\f19e",
|
||||
"message-pending": "\f19f",
|
||||
"message-read": "\f1a0",
|
||||
"message-succeeded": "\f1a1",
|
||||
"microphone": "\f1a2",
|
||||
"microphone-alt": "\f1a3",
|
||||
"monospace": "\f1a4",
|
||||
"more": "\f1a5",
|
||||
"more-circle": "\f1a6",
|
||||
"move-caption-down": "\f1a7",
|
||||
"move-caption-up": "\f1a8",
|
||||
"mute": "\f1a9",
|
||||
"muted": "\f1aa",
|
||||
"my-notes": "\f1ab",
|
||||
"new-chat-filled": "\f1ac",
|
||||
"new-send": "\f1ad",
|
||||
"next": "\f1ae",
|
||||
"next-link": "\f1af",
|
||||
"no-download": "\f1b0",
|
||||
"no-share": "\f1b1",
|
||||
"nochannel": "\f1b2",
|
||||
"noise-suppression": "\f1b3",
|
||||
"non-contacts": "\f1b4",
|
||||
"note": "\f1b5",
|
||||
"one-filled": "\f1b6",
|
||||
"open-in-new-tab": "\f1b7",
|
||||
"password-off": "\f1b8",
|
||||
"pause": "\f1b9",
|
||||
"permissions": "\f1ba",
|
||||
"phone": "\f1bb",
|
||||
"phone-discard": "\f1bc",
|
||||
"phone-discard-outline": "\f1bd",
|
||||
"photo": "\f1be",
|
||||
"pin": "\f1bf",
|
||||
"pin-badge": "\f1c0",
|
||||
"pin-list": "\f1c1",
|
||||
"pinned-chat": "\f1c2",
|
||||
"pinned-message": "\f1c3",
|
||||
"pip": "\f1c4",
|
||||
"play": "\f1c5",
|
||||
"play-story": "\f1c6",
|
||||
"poll": "\f1c7",
|
||||
"poll-badge": "\f1c8",
|
||||
"previous": "\f1c9",
|
||||
"previous-link": "\f1ca",
|
||||
"privacy-policy": "\f1cb",
|
||||
"proof-of-ownership": "\f1cc",
|
||||
"quote": "\f1cd",
|
||||
"quote-text": "\f1ce",
|
||||
"radial-badge": "\f1cf",
|
||||
"rating-icons-level1": "\f1d0",
|
||||
"rating-icons-level10": "\f1d1",
|
||||
"rating-icons-level2": "\f1d2",
|
||||
"rating-icons-level20": "\f1d3",
|
||||
"rating-icons-level3": "\f1d4",
|
||||
"rating-icons-level30": "\f1d5",
|
||||
"rating-icons-level4": "\f1d6",
|
||||
"rating-icons-level40": "\f1d7",
|
||||
"rating-icons-level5": "\f1d8",
|
||||
"rating-icons-level50": "\f1d9",
|
||||
"rating-icons-level6": "\f1da",
|
||||
"rating-icons-level60": "\f1db",
|
||||
"rating-icons-level7": "\f1dc",
|
||||
"rating-icons-level70": "\f1dd",
|
||||
"rating-icons-level8": "\f1de",
|
||||
"rating-icons-level80": "\f1df",
|
||||
"rating-icons-level9": "\f1e0",
|
||||
"rating-icons-level90": "\f1e1",
|
||||
"rating-icons-negative": "\f1e2",
|
||||
"readchats": "\f1e3",
|
||||
"recent": "\f1e4",
|
||||
"redo": "\f1e5",
|
||||
"refund": "\f1e6",
|
||||
"reload": "\f1e7",
|
||||
"remove": "\f1e8",
|
||||
"remove-quote": "\f1e9",
|
||||
"reopen-topic": "\f1ea",
|
||||
"reorder-tabs": "\f1eb",
|
||||
"replace": "\f1ec",
|
||||
"replies": "\f1ed",
|
||||
"reply": "\f1ee",
|
||||
"reply-filled": "\f1ef",
|
||||
"revenue-split": "\f1f0",
|
||||
"revote": "\f1f1",
|
||||
"rotate": "\f1f2",
|
||||
"save-story": "\f1f3",
|
||||
"saved-messages": "\f1f4",
|
||||
"schedule": "\f1f5",
|
||||
"scheduled": "\f1f6",
|
||||
"sd-photo": "\f1f7",
|
||||
"search": "\f1f8",
|
||||
"select": "\f1f9",
|
||||
"select-filled": "\f1fa",
|
||||
"sell": "\f1fb",
|
||||
"sell-outline": "\f1fc",
|
||||
"send": "\f1fd",
|
||||
"send-outline": "\f1fe",
|
||||
"settings": "\f1ff",
|
||||
"settings-filled": "\f200",
|
||||
"share-filled": "\f201",
|
||||
"share-screen": "\f202",
|
||||
"share-screen-outlined": "\f203",
|
||||
"share-screen-stop": "\f204",
|
||||
"show-message": "\f205",
|
||||
"sidebar": "\f206",
|
||||
"skip-next": "\f207",
|
||||
"skip-previous": "\f208",
|
||||
"smallscreen": "\f209",
|
||||
"smile": "\f20a",
|
||||
"sort": "\f20b",
|
||||
"sort-by-date": "\f20c",
|
||||
"sort-by-number": "\f20d",
|
||||
"sort-by-price": "\f20e",
|
||||
"speaker": "\f20f",
|
||||
"speaker-muted-story": "\f210",
|
||||
"speaker-outline": "\f211",
|
||||
"speaker-story": "\f212",
|
||||
"spoiler": "\f213",
|
||||
"spoiler-disable": "\f214",
|
||||
"sport": "\f215",
|
||||
"star": "\f216",
|
||||
"stars-lock": "\f217",
|
||||
"stars-refund": "\f218",
|
||||
"stats": "\f219",
|
||||
"stealth-future": "\f21a",
|
||||
"stealth-past": "\f21b",
|
||||
"stickers": "\f21c",
|
||||
"stop": "\f21d",
|
||||
"stop-raising-hand": "\f21e",
|
||||
"story-caption": "\f21f",
|
||||
"story-expired": "\f220",
|
||||
"story-priority": "\f221",
|
||||
"story-reply": "\f222",
|
||||
"strikethrough": "\f223",
|
||||
"tag": "\f224",
|
||||
"tag-add": "\f225",
|
||||
"tag-crossed": "\f226",
|
||||
"tag-filter": "\f227",
|
||||
"tag-name": "\f228",
|
||||
"timer": "\f229",
|
||||
"toncoin": "\f22a",
|
||||
"tone": "\f22b",
|
||||
"tools": "\f22c",
|
||||
"topic-new": "\f22d",
|
||||
"trade": "\f22e",
|
||||
"transcribe": "\f22f",
|
||||
"truck": "\f230",
|
||||
"unarchive": "\f231",
|
||||
"underlined": "\f232",
|
||||
"understood": "\f233",
|
||||
"undo": "\f234",
|
||||
"unique-profile": "\f235",
|
||||
"unlist": "\f236",
|
||||
"unlist-outline": "\f237",
|
||||
"unlock": "\f238",
|
||||
"unlock-badge": "\f239",
|
||||
"unmute": "\f23a",
|
||||
"unpin": "\f23b",
|
||||
"unread": "\f23c",
|
||||
"up": "\f23d",
|
||||
"user": "\f23e",
|
||||
"user-filled": "\f23f",
|
||||
"user-online": "\f240",
|
||||
"user-stars": "\f241",
|
||||
"user-tag": "\f242",
|
||||
"video": "\f243",
|
||||
"video-outlined": "\f244",
|
||||
"video-stop": "\f245",
|
||||
"view-once": "\f246",
|
||||
"voice-chat": "\f247",
|
||||
"volume-1": "\f248",
|
||||
"volume-2": "\f249",
|
||||
"volume-3": "\f24a",
|
||||
"warning": "\f24b",
|
||||
"web": "\f24c",
|
||||
"webapp": "\f24d",
|
||||
"word-wrap": "\f24e",
|
||||
"zoom-in": "\f24f",
|
||||
"zoom-out": "\f250",
|
||||
"check-filled": "\f13b",
|
||||
"choice-selected": "\f13c",
|
||||
"clock": "\f13d",
|
||||
"clock-edit": "\f13e",
|
||||
"close": "\f13f",
|
||||
"close-circle": "\f140",
|
||||
"close-topic": "\f141",
|
||||
"closed-gift": "\f142",
|
||||
"cloud-download": "\f143",
|
||||
"collapse": "\f144",
|
||||
"collapse-modal": "\f145",
|
||||
"colorize": "\f146",
|
||||
"combine-craft": "\f147",
|
||||
"comments": "\f148",
|
||||
"comments-sticker": "\f149",
|
||||
"copy": "\f14a",
|
||||
"copy-media": "\f14b",
|
||||
"craft": "\f14c",
|
||||
"crop": "\f14d",
|
||||
"crown-take-off": "\f14e",
|
||||
"crown-take-off-outline": "\f14f",
|
||||
"crown-wear": "\f150",
|
||||
"crown-wear-outline": "\f151",
|
||||
"darkmode": "\f152",
|
||||
"data": "\f153",
|
||||
"delete": "\f154",
|
||||
"delete-filled": "\f155",
|
||||
"delete-left": "\f156",
|
||||
"delete-user": "\f157",
|
||||
"diamond": "\f158",
|
||||
"document": "\f159",
|
||||
"double-badge": "\f15a",
|
||||
"down": "\f15b",
|
||||
"download": "\f15c",
|
||||
"dropdown-arrows": "\f15d",
|
||||
"eats": "\f15e",
|
||||
"edit": "\f15f",
|
||||
"email": "\f160",
|
||||
"enter": "\f161",
|
||||
"expand": "\f162",
|
||||
"expand-modal": "\f163",
|
||||
"eye": "\f164",
|
||||
"eye-crossed": "\f165",
|
||||
"eye-crossed-outline": "\f166",
|
||||
"eye-outline": "\f167",
|
||||
"favorite": "\f168",
|
||||
"favorite-filled": "\f169",
|
||||
"file-badge": "\f16a",
|
||||
"flag": "\f16b",
|
||||
"flip": "\f16c",
|
||||
"folder": "\f16d",
|
||||
"folder-badge": "\f16e",
|
||||
"folder-tabs-bot": "\f16f",
|
||||
"folder-tabs-channel": "\f170",
|
||||
"folder-tabs-chat": "\f171",
|
||||
"folder-tabs-chats": "\f172",
|
||||
"folder-tabs-folder": "\f173",
|
||||
"folder-tabs-group": "\f174",
|
||||
"folder-tabs-star": "\f175",
|
||||
"folder-tabs-user": "\f176",
|
||||
"fontsize": "\f177",
|
||||
"forums": "\f178",
|
||||
"forward": "\f179",
|
||||
"fragment": "\f17a",
|
||||
"frozen-time": "\f17b",
|
||||
"fullscreen": "\f17c",
|
||||
"gifs": "\f17d",
|
||||
"gift": "\f17e",
|
||||
"gift-transfer-inline": "\f17f",
|
||||
"group": "\f180",
|
||||
"group-filled": "\f181",
|
||||
"grouped": "\f182",
|
||||
"grouped-disable": "\f183",
|
||||
"hand-stop": "\f184",
|
||||
"hand-stop-filled": "\f185",
|
||||
"hashtag": "\f186",
|
||||
"hd-photo": "\f187",
|
||||
"heart": "\f188",
|
||||
"heart-outline": "\f189",
|
||||
"help": "\f18a",
|
||||
"info": "\f18b",
|
||||
"info-filled": "\f18c",
|
||||
"install": "\f18d",
|
||||
"italic": "\f18e",
|
||||
"key": "\f18f",
|
||||
"keyboard": "\f190",
|
||||
"lamp": "\f191",
|
||||
"language": "\f192",
|
||||
"large-pause": "\f193",
|
||||
"large-play": "\f194",
|
||||
"link": "\f195",
|
||||
"link-badge": "\f196",
|
||||
"link-broken": "\f197",
|
||||
"location": "\f198",
|
||||
"lock": "\f199",
|
||||
"lock-badge": "\f19a",
|
||||
"logout": "\f19b",
|
||||
"loop": "\f19c",
|
||||
"mention": "\f19d",
|
||||
"menu": "\f19e",
|
||||
"message": "\f19f",
|
||||
"message-failed": "\f1a0",
|
||||
"message-pending": "\f1a1",
|
||||
"message-read": "\f1a2",
|
||||
"message-succeeded": "\f1a3",
|
||||
"microphone": "\f1a4",
|
||||
"microphone-alt": "\f1a5",
|
||||
"monospace": "\f1a6",
|
||||
"more": "\f1a7",
|
||||
"more-circle": "\f1a8",
|
||||
"move-caption-down": "\f1a9",
|
||||
"move-caption-up": "\f1aa",
|
||||
"mute": "\f1ab",
|
||||
"muted": "\f1ac",
|
||||
"my-notes": "\f1ad",
|
||||
"new-chat-filled": "\f1ae",
|
||||
"new-send": "\f1af",
|
||||
"next": "\f1b0",
|
||||
"next-link": "\f1b1",
|
||||
"no-download": "\f1b2",
|
||||
"no-share": "\f1b3",
|
||||
"nochannel": "\f1b4",
|
||||
"noise-suppression": "\f1b5",
|
||||
"non-contacts": "\f1b6",
|
||||
"note": "\f1b7",
|
||||
"one-filled": "\f1b8",
|
||||
"open-in-new-tab": "\f1b9",
|
||||
"password-off": "\f1ba",
|
||||
"pause": "\f1bb",
|
||||
"permissions": "\f1bc",
|
||||
"phone": "\f1bd",
|
||||
"phone-discard": "\f1be",
|
||||
"phone-discard-outline": "\f1bf",
|
||||
"photo": "\f1c0",
|
||||
"pin": "\f1c1",
|
||||
"pin-badge": "\f1c2",
|
||||
"pin-list": "\f1c3",
|
||||
"pinned-chat": "\f1c4",
|
||||
"pinned-message": "\f1c5",
|
||||
"pip": "\f1c6",
|
||||
"play": "\f1c7",
|
||||
"play-story": "\f1c8",
|
||||
"poll": "\f1c9",
|
||||
"poll-badge": "\f1ca",
|
||||
"previous": "\f1cb",
|
||||
"previous-link": "\f1cc",
|
||||
"privacy-policy": "\f1cd",
|
||||
"proof-of-ownership": "\f1ce",
|
||||
"quote": "\f1cf",
|
||||
"quote-text": "\f1d0",
|
||||
"radial-badge": "\f1d1",
|
||||
"rating-icons-level1": "\f1d2",
|
||||
"rating-icons-level10": "\f1d3",
|
||||
"rating-icons-level2": "\f1d4",
|
||||
"rating-icons-level20": "\f1d5",
|
||||
"rating-icons-level3": "\f1d6",
|
||||
"rating-icons-level30": "\f1d7",
|
||||
"rating-icons-level4": "\f1d8",
|
||||
"rating-icons-level40": "\f1d9",
|
||||
"rating-icons-level5": "\f1da",
|
||||
"rating-icons-level50": "\f1db",
|
||||
"rating-icons-level6": "\f1dc",
|
||||
"rating-icons-level60": "\f1dd",
|
||||
"rating-icons-level7": "\f1de",
|
||||
"rating-icons-level70": "\f1df",
|
||||
"rating-icons-level8": "\f1e0",
|
||||
"rating-icons-level80": "\f1e1",
|
||||
"rating-icons-level9": "\f1e2",
|
||||
"rating-icons-level90": "\f1e3",
|
||||
"rating-icons-negative": "\f1e4",
|
||||
"readchats": "\f1e5",
|
||||
"recent": "\f1e6",
|
||||
"redo": "\f1e7",
|
||||
"refund": "\f1e8",
|
||||
"reload": "\f1e9",
|
||||
"remove": "\f1ea",
|
||||
"remove-quote": "\f1eb",
|
||||
"reopen-topic": "\f1ec",
|
||||
"reorder-tabs": "\f1ed",
|
||||
"replace": "\f1ee",
|
||||
"replace-round": "\f1ef",
|
||||
"replies": "\f1f0",
|
||||
"reply": "\f1f1",
|
||||
"reply-filled": "\f1f2",
|
||||
"revenue-split": "\f1f3",
|
||||
"revote": "\f1f4",
|
||||
"rotate": "\f1f5",
|
||||
"save-story": "\f1f6",
|
||||
"saved-messages": "\f1f7",
|
||||
"schedule": "\f1f8",
|
||||
"scheduled": "\f1f9",
|
||||
"sd-photo": "\f1fa",
|
||||
"search": "\f1fb",
|
||||
"select": "\f1fc",
|
||||
"select-filled": "\f1fd",
|
||||
"sell": "\f1fe",
|
||||
"sell-outline": "\f1ff",
|
||||
"send": "\f200",
|
||||
"send-outline": "\f201",
|
||||
"settings": "\f202",
|
||||
"settings-filled": "\f203",
|
||||
"share-filled": "\f204",
|
||||
"share-screen": "\f205",
|
||||
"share-screen-outlined": "\f206",
|
||||
"share-screen-stop": "\f207",
|
||||
"show-message": "\f208",
|
||||
"sidebar": "\f209",
|
||||
"skip-next": "\f20a",
|
||||
"skip-previous": "\f20b",
|
||||
"smallscreen": "\f20c",
|
||||
"smile": "\f20d",
|
||||
"sort": "\f20e",
|
||||
"sort-by-date": "\f20f",
|
||||
"sort-by-number": "\f210",
|
||||
"sort-by-price": "\f211",
|
||||
"speaker": "\f212",
|
||||
"speaker-muted-story": "\f213",
|
||||
"speaker-outline": "\f214",
|
||||
"speaker-story": "\f215",
|
||||
"spoiler": "\f216",
|
||||
"spoiler-disable": "\f217",
|
||||
"sport": "\f218",
|
||||
"star": "\f219",
|
||||
"stars-lock": "\f21a",
|
||||
"stars-refund": "\f21b",
|
||||
"stats": "\f21c",
|
||||
"stealth-future": "\f21d",
|
||||
"stealth-past": "\f21e",
|
||||
"stickers": "\f21f",
|
||||
"stop": "\f220",
|
||||
"stop-raising-hand": "\f221",
|
||||
"story-caption": "\f222",
|
||||
"story-expired": "\f223",
|
||||
"story-priority": "\f224",
|
||||
"story-reply": "\f225",
|
||||
"strikethrough": "\f226",
|
||||
"tag": "\f227",
|
||||
"tag-add": "\f228",
|
||||
"tag-crossed": "\f229",
|
||||
"tag-filter": "\f22a",
|
||||
"tag-name": "\f22b",
|
||||
"timer": "\f22c",
|
||||
"timer-filled": "\f22d",
|
||||
"toncoin": "\f22e",
|
||||
"tone": "\f22f",
|
||||
"tools": "\f230",
|
||||
"topic-new": "\f231",
|
||||
"trade": "\f232",
|
||||
"transcribe": "\f233",
|
||||
"truck": "\f234",
|
||||
"unarchive": "\f235",
|
||||
"underlined": "\f236",
|
||||
"understood": "\f237",
|
||||
"undo": "\f238",
|
||||
"unique-profile": "\f239",
|
||||
"unlist": "\f23a",
|
||||
"unlist-outline": "\f23b",
|
||||
"unlock": "\f23c",
|
||||
"unlock-badge": "\f23d",
|
||||
"unmute": "\f23e",
|
||||
"unpin": "\f23f",
|
||||
"unread": "\f240",
|
||||
"up": "\f241",
|
||||
"user": "\f242",
|
||||
"user-filled": "\f243",
|
||||
"user-online": "\f244",
|
||||
"user-stars": "\f245",
|
||||
"user-tag": "\f246",
|
||||
"video": "\f247",
|
||||
"video-outlined": "\f248",
|
||||
"video-stop": "\f249",
|
||||
"view-once": "\f24a",
|
||||
"voice-chat": "\f24b",
|
||||
"volume-1": "\f24c",
|
||||
"volume-2": "\f24d",
|
||||
"volume-3": "\f24e",
|
||||
"warning": "\f24f",
|
||||
"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
@ -57,6 +57,8 @@ export type FontIconName =
|
||||
| 'chats-badge'
|
||||
| 'check'
|
||||
| 'check-bold'
|
||||
| 'check-filled'
|
||||
| 'choice-selected'
|
||||
| 'clock'
|
||||
| 'clock-edit'
|
||||
| 'close'
|
||||
@ -235,6 +237,7 @@ export type FontIconName =
|
||||
| 'reopen-topic'
|
||||
| 'reorder-tabs'
|
||||
| 'replace'
|
||||
| 'replace-round'
|
||||
| 'replies'
|
||||
| 'reply'
|
||||
| 'reply-filled'
|
||||
@ -296,6 +299,7 @@ export type FontIconName =
|
||||
| 'tag-filter'
|
||||
| 'tag-name'
|
||||
| 'timer'
|
||||
| 'timer-filled'
|
||||
| 'toncoin'
|
||||
| 'tone'
|
||||
| 'tools'
|
||||
|
||||
25
src/types/language.d.ts
vendored
25
src/types/language.d.ts
vendored
@ -597,10 +597,23 @@ export interface LangPair {
|
||||
'CreatePollAddOption': undefined;
|
||||
'PollsChooseCorrect': undefined;
|
||||
'AskAQuestion': undefined;
|
||||
'PollOptions': undefined;
|
||||
'PollAnonymous': undefined;
|
||||
'PollAnswersVisible': undefined;
|
||||
'PollAnswersVisibleDescription': undefined;
|
||||
'PollMultiple': undefined;
|
||||
'PollMultipleDescription': 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;
|
||||
'CreatePollExplanationInfo': undefined;
|
||||
'VoipGroupPersonalAccount': undefined;
|
||||
@ -2106,6 +2119,11 @@ export interface LangPair {
|
||||
'AiMessageEditorFrom': undefined;
|
||||
'AiMessageEditorTo': undefined;
|
||||
'ButtonHelp': undefined;
|
||||
'PollModalQuestionTitle': undefined;
|
||||
'PollModalOptionsTitle': undefined;
|
||||
'PollModalSettingsTitle': undefined;
|
||||
'PollModalAddNoMore': undefined;
|
||||
'PollDurationOther': undefined;
|
||||
}
|
||||
|
||||
export interface LangPairWithVariables<V = LangVariable> {
|
||||
@ -4224,6 +4242,9 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
|
||||
'FwdMessagesToChats': {
|
||||
'count': V;
|
||||
};
|
||||
'PollModalAddMoreText': {
|
||||
'count': V;
|
||||
};
|
||||
}
|
||||
export type RegularLangKey = keyof LangPair;
|
||||
export type RegularLangKeyWithVariables = keyof LangPairWithVariables;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user