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