Poll: Add confetti, retract vote, close poll (#1906)
This commit is contained in:
parent
25e7545680
commit
918f1cdf7b
@ -742,7 +742,7 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice {
|
||||
|
||||
export function buildPollResults(pollResults: GramJs.PollResults): ApiPoll['results'] {
|
||||
const {
|
||||
results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities,
|
||||
results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min,
|
||||
} = pollResults;
|
||||
const results = rawResults && rawResults.map(({
|
||||
option, chosen, correct, voters,
|
||||
@ -754,6 +754,7 @@ export function buildPollResults(pollResults: GramJs.PollResults): ApiPoll['resu
|
||||
}));
|
||||
|
||||
return {
|
||||
isMin: min,
|
||||
totalVoters,
|
||||
recentVoterIds: recentVoters?.map((id) => buildApiPeerId(id, 'user')),
|
||||
results,
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
ApiSticker,
|
||||
ApiVideo,
|
||||
ApiThemeParameters,
|
||||
ApiPoll,
|
||||
} from '../../types';
|
||||
import {
|
||||
ApiMessageEntityTypes,
|
||||
@ -197,6 +198,27 @@ export function buildInputPoll(pollParams: ApiNewPoll, randomId: BigInt.BigInteg
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInputPollFromExisting(poll: ApiPoll, shouldClose = false) {
|
||||
return new GramJs.InputMediaPoll({
|
||||
poll: new GramJs.Poll({
|
||||
id: BigInt(poll.id),
|
||||
publicVoters: poll.summary.isPublic,
|
||||
question: poll.summary.question,
|
||||
answers: poll.summary.answers.map(({ text, option }) => {
|
||||
return new GramJs.PollAnswer({ text, option: deserializeBytes(option) });
|
||||
}),
|
||||
quiz: poll.summary.quiz,
|
||||
multipleChoice: poll.summary.multipleChoice,
|
||||
closeDate: poll.summary.closeDate,
|
||||
closePeriod: poll.summary.closePeriod,
|
||||
closed: shouldClose ? true : poll.summary.closed,
|
||||
}),
|
||||
correctAnswers: poll.results.results?.filter((o) => o.isCorrect).map((o) => deserializeBytes(o.option)),
|
||||
solution: poll.results.solution,
|
||||
solutionEntities: poll.results.solutionEntities?.map(buildMtpMessageEntity),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildFilterFromApiFolder(folder: ApiChatFolder): GramJs.DialogFilter {
|
||||
const {
|
||||
emoticon,
|
||||
|
||||
@ -28,7 +28,7 @@ export {
|
||||
fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate,
|
||||
fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages,
|
||||
reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs,
|
||||
saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions,
|
||||
saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, closePoll,
|
||||
} from './messages';
|
||||
|
||||
export {
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ApiSponsoredMessage,
|
||||
ApiSendMessageAction,
|
||||
ApiContact,
|
||||
ApiPoll,
|
||||
} from '../../types';
|
||||
import {
|
||||
MAIN_THREAD_ID,
|
||||
@ -51,6 +52,7 @@ import {
|
||||
isMessageWithMedia,
|
||||
isServiceMessageWithMedia,
|
||||
buildSendMessageAction,
|
||||
buildInputPollFromExisting,
|
||||
} from '../gramjsBuilders';
|
||||
import localDb from '../localDb';
|
||||
import { buildApiChatFromPreview } from '../apiBuilders/chats';
|
||||
@ -1051,6 +1053,22 @@ export async function sendPollVote({
|
||||
}), true);
|
||||
}
|
||||
|
||||
export async function closePoll({
|
||||
chat, messageId, poll,
|
||||
} : {
|
||||
chat: ApiChat;
|
||||
messageId: number;
|
||||
poll: ApiPoll;
|
||||
}) {
|
||||
const { id, accessHash } = chat;
|
||||
|
||||
await invokeRequest(new GramJs.messages.EditMessage({
|
||||
peer: buildInputPeer(id, accessHash),
|
||||
id: messageId,
|
||||
media: buildInputPollFromExisting(poll, true),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function loadPollOptionResults({
|
||||
chat, messageId, option, offset, limit, shouldResetVoters,
|
||||
}: {
|
||||
|
||||
@ -125,6 +125,7 @@ export interface ApiPoll {
|
||||
closeDate?: number;
|
||||
};
|
||||
results: {
|
||||
isMin?: true;
|
||||
results?: ApiPollResult[];
|
||||
totalVoters?: number;
|
||||
recentVoterIds?: string[];
|
||||
|
||||
BIN
src/assets/tgs/settings/Experimental.tgs
Normal file
BIN
src/assets/tgs/settings/Experimental.tgs
Normal file
Binary file not shown.
@ -8,6 +8,7 @@ import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs';
|
||||
import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs';
|
||||
import Lock from '../../../assets/tgs/settings/Lock.tgs';
|
||||
import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs';
|
||||
import Experimental from '../../../assets/tgs/settings/Experimental.tgs';
|
||||
|
||||
import CameraFlip from '../../../assets/tgs/calls/CameraFlip.tgs';
|
||||
import HandFilled from '../../../assets/tgs/calls/HandFilled.tgs';
|
||||
@ -51,4 +52,5 @@ export const LOCAL_TGS_URLS = {
|
||||
Invite,
|
||||
QrPlane,
|
||||
Congratulations,
|
||||
Experimental,
|
||||
};
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
font-weight: 500;
|
||||
margin-left: 1.375rem;
|
||||
margin-right: auto;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
|
||||
@ -142,6 +142,7 @@ const LeftColumn: FC<StateProps> = ({
|
||||
case SettingsScreens.Privacy:
|
||||
case SettingsScreens.ActiveSessions:
|
||||
case SettingsScreens.Language:
|
||||
case SettingsScreens.Experimental:
|
||||
setSettingsScreen(SettingsScreens.Main);
|
||||
return;
|
||||
|
||||
|
||||
@ -27,6 +27,7 @@ import SettingsTwoFa from './twoFa/SettingsTwoFa';
|
||||
import SettingsPrivacyVisibilityExceptionList from './SettingsPrivacyVisibilityExceptionList';
|
||||
import SettingsQuickReaction from './SettingsQuickReaction';
|
||||
import SettingsPasscode from './passcode/SettingsPasscode';
|
||||
import SettingsExperimental from './SettingsExperimental';
|
||||
|
||||
import './Settings.scss';
|
||||
|
||||
@ -237,6 +238,10 @@ const Settings: FC<OwnProps> = ({
|
||||
return (
|
||||
<SettingsLanguage isActive={isScreenActive} onReset={handleReset} />
|
||||
);
|
||||
case SettingsScreens.Experimental:
|
||||
return (
|
||||
<SettingsExperimental isActive={isScreenActive} onReset={handleReset} />
|
||||
);
|
||||
case SettingsScreens.GeneralChatBackground:
|
||||
return (
|
||||
<SettingsGeneralBackground
|
||||
|
||||
55
src/components/left/settings/SettingsExperimental.tsx
Normal file
55
src/components/left/settings/SettingsExperimental.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import React, { memo } from '../../../lib/teact/teact';
|
||||
|
||||
import { getActions } from '../../../global';
|
||||
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
||||
|
||||
import useHistoryBack from '../../../hooks/useHistoryBack';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
|
||||
import AnimatedIcon from '../../common/AnimatedIcon';
|
||||
import ListItem from '../../ui/ListItem';
|
||||
|
||||
type OwnProps = {
|
||||
isActive?: boolean;
|
||||
onReset: () => void;
|
||||
};
|
||||
|
||||
const SettingsExperimental: FC<OwnProps> = ({
|
||||
isActive,
|
||||
onReset,
|
||||
}) => {
|
||||
const { requestConfetti } = getActions();
|
||||
const lang = useLang();
|
||||
|
||||
useHistoryBack({
|
||||
isActive,
|
||||
onBack: onReset,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="settings-content custom-scroll">
|
||||
<div className="settings-content-header no-border">
|
||||
<AnimatedIcon
|
||||
tgsUrl={LOCAL_TGS_URLS.Experimental}
|
||||
size={200}
|
||||
className="experimental-duck"
|
||||
nonInteractive
|
||||
noLoop={false}
|
||||
/>
|
||||
<p className="settings-item-description" dir="auto">{lang('lng_settings_experimental_about')}</p>
|
||||
</div>
|
||||
<div className="settings-item">
|
||||
<ListItem
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick={() => requestConfetti()}
|
||||
icon="animations"
|
||||
>
|
||||
<div className="title">Launch some confetti!</div>
|
||||
</ListItem>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(SettingsExperimental);
|
||||
@ -8,6 +8,7 @@ import { SettingsScreens } from '../../../types';
|
||||
|
||||
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useMultiClick from '../../../hooks/useMultiClick';
|
||||
|
||||
import DropdownMenu from '../../ui/DropdownMenu';
|
||||
import MenuItem from '../../ui/MenuItem';
|
||||
@ -37,6 +38,10 @@ const SettingsHeader: FC<OwnProps> = ({
|
||||
const [isSignOutDialogOpen, setIsSignOutDialogOpen] = useState(false);
|
||||
const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false);
|
||||
|
||||
const handleMultiClick = useMultiClick(5, () => {
|
||||
onScreenSelect(SettingsScreens.Experimental);
|
||||
});
|
||||
|
||||
const openSignOutConfirmation = useCallback(() => {
|
||||
setIsSignOutDialogOpen(true);
|
||||
}, []);
|
||||
@ -98,6 +103,8 @@ const SettingsHeader: FC<OwnProps> = ({
|
||||
return <h3>{lang('PrivacySettings')}</h3>;
|
||||
case SettingsScreens.Language:
|
||||
return <h3>{lang('Language')}</h3>;
|
||||
case SettingsScreens.Experimental:
|
||||
return <h3>{lang('lng_settings_experimental')}</h3>;
|
||||
|
||||
case SettingsScreens.GeneralChatBackground:
|
||||
return <h3>{lang('ChatBackground')}</h3>;
|
||||
@ -228,7 +235,10 @@ const SettingsHeader: FC<OwnProps> = ({
|
||||
default:
|
||||
return (
|
||||
<div className="settings-main-header">
|
||||
<h3>{lang('SETTINGS')}</h3>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
|
||||
<h3 onClick={handleMultiClick}>
|
||||
{lang('SETTINGS')}
|
||||
</h3>
|
||||
|
||||
<Button
|
||||
round
|
||||
|
||||
10
src/components/main/ConfettiContainer.module.scss
Normal file
10
src/components/main/ConfettiContainer.module.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-confetti);
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
193
src/components/main/ConfettiContainer.tsx
Normal file
193
src/components/main/ConfettiContainer.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React, { memo, useRef } from '../../lib/teact/teact';
|
||||
import { withGlobal } from '../../global';
|
||||
|
||||
import type { FC } from '../../lib/teact/teact';
|
||||
|
||||
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
|
||||
import { pick } from '../../util/iteratees';
|
||||
|
||||
import useWindowSize from '../../hooks/useWindowSize';
|
||||
import useOnChange from '../../hooks/useOnChange';
|
||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||
|
||||
import styles from './ConfettiContainer.module.scss';
|
||||
|
||||
type StateProps = {
|
||||
lastConfettiTime?: number;
|
||||
};
|
||||
|
||||
interface Confetti {
|
||||
pos: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
velocity: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
size: number;
|
||||
color: string;
|
||||
flicker: number;
|
||||
flickerFrequency: number;
|
||||
rotation: number;
|
||||
lastDrawnAt: number;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
const CONFETTI_FADEOUT_TIMEOUT = 10000;
|
||||
const DEFAULT_CONFETTI_AMOUNT = IS_SINGLE_COLUMN_LAYOUT ? 50 : 100;
|
||||
const DEFAULT_CONFETTI_SIZE = 15;
|
||||
const CONFETTI_COLORS = ['#E8BC2C', '#D0049E', '#02CBFE', '#5723FD', '#FE8C27', '#6CB859'];
|
||||
|
||||
const ConfettiContainer: FC<StateProps> = ({ lastConfettiTime }) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const confettiRef = useRef<Confetti[]>([]);
|
||||
const isRafStartedRef = useRef(false);
|
||||
const windowSize = useWindowSize();
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
function generateConfetti(width: number, height: number, amount = DEFAULT_CONFETTI_AMOUNT) {
|
||||
for (let i = 0; i < amount; i++) {
|
||||
const leftSide = i % 2;
|
||||
const pos = {
|
||||
x: width * (leftSide ? -0.1 : 1.1),
|
||||
y: height * 0.75,
|
||||
};
|
||||
const randomX = Math.random() * width * 0.8;
|
||||
const randomY = -height / 2 - Math.random() * height * 0.5;
|
||||
const velocity = {
|
||||
x: leftSide ? randomX : randomX * -1,
|
||||
y: randomY,
|
||||
};
|
||||
|
||||
const randomColor = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
||||
const size = DEFAULT_CONFETTI_SIZE;
|
||||
confettiRef.current.push({
|
||||
pos,
|
||||
size,
|
||||
color: randomColor,
|
||||
velocity,
|
||||
flicker: size,
|
||||
flickerFrequency: Math.random() * 0.2,
|
||||
rotation: 0,
|
||||
lastDrawnAt: Date.now(),
|
||||
frameCount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updateCanvas = () => {
|
||||
if (!canvasRef.current || !isRafStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = canvas;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
const confettiToRemove: Confetti[] = [];
|
||||
confettiRef.current.forEach((confetti, i) => {
|
||||
const {
|
||||
pos,
|
||||
velocity,
|
||||
size,
|
||||
color,
|
||||
flicker,
|
||||
flickerFrequency,
|
||||
rotation,
|
||||
lastDrawnAt,
|
||||
frameCount,
|
||||
} = confetti;
|
||||
const diff = (Date.now() - lastDrawnAt) / 1000;
|
||||
|
||||
const newPos = {
|
||||
x: pos.x + velocity.x * diff,
|
||||
y: pos.y + velocity.y * diff,
|
||||
};
|
||||
|
||||
const newVelocity = {
|
||||
x: velocity.x * 0.99, // Air Resistance
|
||||
y: velocity.y += diff * 500, // Gravity
|
||||
};
|
||||
|
||||
const newFlicker = size * Math.abs(Math.sin(frameCount * flickerFrequency));
|
||||
const newRotation = 5 * frameCount * flickerFrequency * (Math.PI / 180);
|
||||
|
||||
const newFrameCount = frameCount + 1;
|
||||
const newLastDrawnAt = Date.now();
|
||||
|
||||
const shouldRemove = newPos.y > height + confetti.size;
|
||||
if (shouldRemove) {
|
||||
confettiToRemove.push(confetti);
|
||||
return;
|
||||
}
|
||||
|
||||
const newConfetti = {
|
||||
...confetti,
|
||||
pos: newPos,
|
||||
velocity: newVelocity,
|
||||
flicker: newFlicker,
|
||||
rotation: newRotation,
|
||||
lastDrawnAt: newLastDrawnAt,
|
||||
frameCount: newFrameCount,
|
||||
};
|
||||
|
||||
confettiRef.current[i] = newConfetti;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(
|
||||
pos.x,
|
||||
pos.y,
|
||||
size,
|
||||
flicker,
|
||||
rotation,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
);
|
||||
ctx.fill();
|
||||
});
|
||||
confettiRef.current = confettiRef.current.filter((confetti) => !confettiToRemove.includes(confetti));
|
||||
if (confettiRef.current.length) {
|
||||
requestAnimationFrame(updateCanvas);
|
||||
} else {
|
||||
isRafStartedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useOnChange(([prevConfettiTime]) => {
|
||||
let hideTimeout: ReturnType<typeof setTimeout>;
|
||||
if (prevConfettiTime !== lastConfettiTime) {
|
||||
generateConfetti(windowSize.width, windowSize.height);
|
||||
hideTimeout = setTimeout(forceUpdate, CONFETTI_FADEOUT_TIMEOUT);
|
||||
if (!isRafStartedRef.current) {
|
||||
isRafStartedRef.current = true;
|
||||
requestAnimationFrame(updateCanvas);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
}
|
||||
};
|
||||
}, [lastConfettiTime, updateCanvas, windowSize]);
|
||||
|
||||
if (!lastConfettiTime || Date.now() - lastConfettiTime > CONFETTI_FADEOUT_TIMEOUT) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="Confetti" className={styles.root}>
|
||||
<canvas ref={canvasRef} className={styles.canvas} width={windowSize.width} height={windowSize.height} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withGlobal(
|
||||
(global): StateProps => pick(global, ['lastConfettiTime']),
|
||||
)(ConfettiContainer));
|
||||
@ -62,6 +62,7 @@ import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async';
|
||||
import WebAppModal from './WebAppModal.async';
|
||||
import BotTrustModal from './BotTrustModal.async';
|
||||
import BotAttachModal from './BotAttachModal.async';
|
||||
import ConfettiContainer from './ConfettiContainer';
|
||||
import UrlAuthModal from './UrlAuthModal.async';
|
||||
|
||||
import './Main.scss';
|
||||
@ -392,6 +393,7 @@ const Main: FC<StateProps> = ({
|
||||
<GameModal openedGame={openedGame} gameTitle={gameTitle} />
|
||||
<WebAppModal webApp={webApp} />
|
||||
<DownloadManager />
|
||||
<ConfettiContainer />
|
||||
<PhoneCall isActive={isPhoneCallActive} />
|
||||
<UnreadCount isForAppBadge />
|
||||
<RatePhoneCallModal isOpen={isRatePhoneCallModalOpen} />
|
||||
|
||||
@ -992,7 +992,7 @@ const Composer: FC<OwnProps & StateProps> = ({
|
||||
<PollModal
|
||||
isOpen={pollModal.isOpen}
|
||||
isQuiz={pollModal.isQuiz}
|
||||
shouldBeAnonimous={isChannel}
|
||||
shouldBeAnonymous={isChannel}
|
||||
onClear={closePollModal}
|
||||
onSend={handlePollSend}
|
||||
/>
|
||||
|
||||
@ -20,7 +20,7 @@ import './PollModal.scss';
|
||||
|
||||
export type OwnProps = {
|
||||
isOpen: boolean;
|
||||
shouldBeAnonimous?: boolean;
|
||||
shouldBeAnonymous?: boolean;
|
||||
isQuiz?: boolean;
|
||||
onSend: (pollSummary: ApiNewPoll) => void;
|
||||
onClear: () => void;
|
||||
@ -33,7 +33,7 @@ const MAX_QUESTION_LENGTH = 255;
|
||||
const MAX_SOLUTION_LENGTH = 200;
|
||||
|
||||
const PollModal: FC<OwnProps> = ({
|
||||
isOpen, isQuiz, shouldBeAnonimous, onSend, onClear,
|
||||
isOpen, isQuiz, shouldBeAnonymous, onSend, onClear,
|
||||
}) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const questionInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -324,7 +324,7 @@ const PollModal: FC<OwnProps> = ({
|
||||
<div className="options-divider" />
|
||||
|
||||
<div className="quiz-mode">
|
||||
{!shouldBeAnonimous && (
|
||||
{!shouldBeAnonymous && (
|
||||
<Checkbox
|
||||
label={lang('PollAnonymous')}
|
||||
checked={isAnonymous}
|
||||
|
||||
@ -23,16 +23,18 @@ import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
|
||||
import { getDayStartAt } from '../../../util/dateFormat';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { REM } from '../../common/helpers/mediaDimensions';
|
||||
|
||||
import { copyTextToClipboard } from '../../../util/clipboard';
|
||||
|
||||
import useShowTransition from '../../../hooks/useShowTransition';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
|
||||
import DeleteMessageModal from '../../common/DeleteMessageModal';
|
||||
import ReportModal from '../../common/ReportModal';
|
||||
import PinMessageModal from '../../common/PinMessageModal';
|
||||
import MessageContextMenu from './MessageContextMenu';
|
||||
import CalendarModal from '../../common/CalendarModal';
|
||||
import ConfirmDialog from '../../ui/ConfirmDialog';
|
||||
|
||||
const START_SIZE = 2 * REM;
|
||||
|
||||
@ -71,6 +73,8 @@ type StateProps = {
|
||||
canSelect?: boolean;
|
||||
canDownload?: boolean;
|
||||
canSaveGif?: boolean;
|
||||
canRevote?: boolean;
|
||||
canClosePoll?: boolean;
|
||||
activeDownloads: number[];
|
||||
canShowSeenBy?: boolean;
|
||||
enabledReactions?: string[];
|
||||
@ -109,6 +113,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
canSelect,
|
||||
canDownload,
|
||||
canSaveGif,
|
||||
canRevote,
|
||||
canClosePoll,
|
||||
activeDownloads,
|
||||
canShowSeenBy,
|
||||
}) => {
|
||||
@ -132,14 +138,18 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
loadReactors,
|
||||
copyMessagesByIds,
|
||||
saveGif,
|
||||
cancelPollVote,
|
||||
closePoll,
|
||||
} = getActions();
|
||||
|
||||
const lang = useLang();
|
||||
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(true);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
|
||||
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
|
||||
const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag();
|
||||
|
||||
useEffect(() => {
|
||||
if (canShowSeenBy && isOpen) {
|
||||
@ -254,6 +264,16 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
unfaveSticker({ sticker: message.content.sticker });
|
||||
}, [closeMenu, message.content.sticker, unfaveSticker]);
|
||||
|
||||
const handleCancelVote = useCallback(() => {
|
||||
closeMenu();
|
||||
cancelPollVote({ chatId: message.chatId, messageId: message.id });
|
||||
}, [closeMenu, message, cancelPollVote]);
|
||||
|
||||
const handlePollClose = useCallback(() => {
|
||||
closeMenu();
|
||||
closePoll({ chatId: message.chatId, messageId: message.id });
|
||||
}, [closeMenu, message, closePoll]);
|
||||
|
||||
const handleSelectMessage = useCallback(() => {
|
||||
const params = album?.messages
|
||||
? {
|
||||
@ -373,6 +393,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
canSelect={canSelect}
|
||||
canDownload={canDownload}
|
||||
canSaveGif={canSaveGif}
|
||||
canRevote={canRevote}
|
||||
canClosePoll={canClosePoll}
|
||||
canShowSeenBy={canShowSeenBy}
|
||||
isDownloading={isDownloading}
|
||||
seenByRecentUsers={seenByRecentUsers}
|
||||
@ -394,6 +416,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
onCopyNumber={handleCopyNumber}
|
||||
onDownload={handleDownloadClick}
|
||||
onSaveGif={handleSaveGif}
|
||||
onCancelVote={handleCancelVote}
|
||||
onClosePoll={openClosePollDialog}
|
||||
onShowSeenBy={handleOpenSeenByModal}
|
||||
onSendReaction={handleSendReaction}
|
||||
onShowReactors={handleOpenReactorListModal}
|
||||
@ -416,6 +440,13 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
|
||||
chatId={message.chatId}
|
||||
onClose={closePinModal}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={isClosePollDialogOpen}
|
||||
onClose={closeClosePollDialog}
|
||||
text={lang('lng_polls_stop_warning')}
|
||||
confirmLabel={lang('lng_polls_stop_sure')}
|
||||
confirmHandler={handlePollClose}
|
||||
/>
|
||||
{canReschedule && (
|
||||
<CalendarModal
|
||||
isOpen={isCalendarOpen}
|
||||
@ -453,6 +484,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
canSelect,
|
||||
canDownload,
|
||||
canSaveGif,
|
||||
canRevote,
|
||||
canClosePoll,
|
||||
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
|
||||
const isPinned = messageListType === 'pinned';
|
||||
const isScheduled = messageListType === 'scheduled';
|
||||
@ -494,6 +527,8 @@ export default memo(withGlobal<OwnProps>(
|
||||
canSelect,
|
||||
canDownload: !isProtected && canDownload,
|
||||
canSaveGif: !isProtected && canSaveGif,
|
||||
canRevote,
|
||||
canClosePoll: !isScheduled && canClosePoll,
|
||||
activeDownloads,
|
||||
canShowSeenBy,
|
||||
enabledReactions: chat?.isForbidden ? undefined : chat?.fullInfo?.enabledReactions,
|
||||
|
||||
@ -50,6 +50,8 @@ type OwnProps = {
|
||||
isPrivate?: boolean;
|
||||
canDownload?: boolean;
|
||||
canSaveGif?: boolean;
|
||||
canRevote?: boolean;
|
||||
canClosePoll?: boolean;
|
||||
isDownloading?: boolean;
|
||||
canShowSeenBy?: boolean;
|
||||
seenByRecentUsers?: ApiUser[];
|
||||
@ -72,6 +74,8 @@ type OwnProps = {
|
||||
onCopyNumber?: () => void;
|
||||
onDownload?: () => void;
|
||||
onSaveGif?: () => void;
|
||||
onCancelVote?: () => void;
|
||||
onClosePoll?: () => void;
|
||||
onShowSeenBy?: () => void;
|
||||
onShowReactors?: () => void;
|
||||
onSendReaction: (reaction: string | undefined, x: number, y: number) => void;
|
||||
@ -104,6 +108,8 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
canSelect,
|
||||
canDownload,
|
||||
canSaveGif,
|
||||
canRevote,
|
||||
canClosePoll,
|
||||
isDownloading,
|
||||
canShowSeenBy,
|
||||
canShowReactionsCount,
|
||||
@ -128,6 +134,8 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
onCopyNumber,
|
||||
onDownload,
|
||||
onSaveGif,
|
||||
onCancelVote,
|
||||
onClosePoll,
|
||||
onShowSeenBy,
|
||||
onShowReactors,
|
||||
onSendReaction,
|
||||
@ -260,6 +268,8 @@ const MessageContextMenu: FC<OwnProps> = ({
|
||||
{canPin && <MenuItem icon="pin" onClick={onPin}>{lang('DialogPin')}</MenuItem>}
|
||||
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
|
||||
{canSaveGif && <MenuItem icon="gifs" onClick={onSaveGif}>{lang('lng_context_save_gif')}</MenuItem>}
|
||||
{canRevote && <MenuItem icon="revote" onClick={onCancelVote}>{lang('lng_polls_retract')}</MenuItem>}
|
||||
{canClosePoll && <MenuItem icon="stop" onClick={onClosePoll}>{lang('lng_polls_stop')}</MenuItem>}
|
||||
{canDownload && (
|
||||
<MenuItem icon="download" onClick={onDownload}>
|
||||
{isDownloading ? lang('lng_context_cancel_download') : lang('lng_media_download')}
|
||||
|
||||
@ -54,14 +54,7 @@
|
||||
&::before {
|
||||
left: 0.125rem;
|
||||
background-color: var(--background-color);
|
||||
|
||||
.theme-dark & {
|
||||
--color-borders: var(--color-borders-input);
|
||||
}
|
||||
|
||||
.Message.own & {
|
||||
--color-borders: var(--accent-color);
|
||||
}
|
||||
--color-borders-input: var(--secondary-color);
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
||||
@ -52,7 +52,7 @@ const Poll: FC<OwnProps & StateProps> = ({
|
||||
onSendVote,
|
||||
serverTimeOffset,
|
||||
}) => {
|
||||
const { loadMessage, openPollResults } = getActions();
|
||||
const { loadMessage, openPollResults, requestConfetti } = getActions();
|
||||
|
||||
const { id: messageId, chatId } = message;
|
||||
const { summary, results } = poll;
|
||||
@ -87,14 +87,14 @@ const Poll: FC<OwnProps & StateProps> = ({
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isSubmitting
|
||||
&& poll.results.results
|
||||
&& poll.results.results.some((result) => result.isChosen)
|
||||
) {
|
||||
const chosen = poll.results.results?.find((result) => result.isChosen);
|
||||
if (isSubmitting && chosen) {
|
||||
if (chosen.isCorrect) {
|
||||
requestConfetti();
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [isSubmitting, poll.results.results]);
|
||||
}, [isSubmitting, poll.results.results, requestConfetti]);
|
||||
|
||||
useEffect(() => {
|
||||
if (closePeriod > 0) {
|
||||
@ -216,7 +216,7 @@ const Poll: FC<OwnProps & StateProps> = ({
|
||||
return (
|
||||
<PollOption
|
||||
key={answer.option}
|
||||
shouldAnimate={wasSubmitted}
|
||||
shouldAnimate={wasSubmitted || !canVote}
|
||||
answer={answer}
|
||||
voteResults={voteResults}
|
||||
totalVoters={totalVoters}
|
||||
@ -336,14 +336,14 @@ function getPollTypeString(summary: ApiPoll['summary']) {
|
||||
return NBSP;
|
||||
}
|
||||
|
||||
if (summary.quiz) {
|
||||
return summary.isPublic ? 'QuizPoll' : 'AnonymousQuizPoll';
|
||||
}
|
||||
|
||||
if (summary.closed) {
|
||||
return 'FinalResults';
|
||||
}
|
||||
|
||||
if (summary.quiz) {
|
||||
return summary.isPublic ? 'QuizPoll' : 'AnonymousQuizPoll';
|
||||
}
|
||||
|
||||
return summary.isPublic ? 'PublicPoll' : 'AnonymousPoll';
|
||||
}
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ const PollOption: FC<OwnProps> = ({
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const lineRef = useRef<HTMLDivElement>(null);
|
||||
const lineWidth = result ? getPercentage(result.votersCount, maxVotersCount || 0) : 0;
|
||||
const isAnimationDoesNotStart = finalPercent < answerPercent;
|
||||
const isAnimationDoesNotStart = finalPercent !== answerPercent;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAnimate) {
|
||||
|
||||
@ -573,6 +573,24 @@ addActionHandler('sendPollVote', (global, actions, payload) => {
|
||||
}
|
||||
});
|
||||
|
||||
addActionHandler('cancelPollVote', (global, actions, payload) => {
|
||||
const { chatId, messageId } = payload!;
|
||||
const chat = selectChat(global, chatId);
|
||||
|
||||
if (chat) {
|
||||
void callApi('sendPollVote', { chat, messageId, options: [] });
|
||||
}
|
||||
});
|
||||
|
||||
addActionHandler('closePoll', (global, actions, payload) => {
|
||||
const { chatId, messageId } = payload;
|
||||
const chat = selectChat(global, chatId);
|
||||
const poll = selectChatMessage(global, chatId, messageId)?.content.poll;
|
||||
if (chat && poll) {
|
||||
void callApi('closePoll', { chat, messageId, poll });
|
||||
}
|
||||
});
|
||||
|
||||
addActionHandler('loadPollOptionResults', (global, actions, payload) => {
|
||||
const {
|
||||
chat, messageId, option, offset, limit, shouldResetVoters,
|
||||
|
||||
@ -389,22 +389,23 @@ addActionHandler('apiUpdate', (global, actions, update) => {
|
||||
const message = selectChatMessageByPollId(global, pollId);
|
||||
|
||||
if (message?.content.poll) {
|
||||
const updatedPoll = { ...message.content.poll, ...pollUpdate };
|
||||
|
||||
// Workaround for poll update bug: `chosen` option gets reset when someone votes after current user
|
||||
const { results: updatedResults } = updatedPoll.results || {};
|
||||
if (updatedResults && !updatedResults.some(((result) => result.isChosen))) {
|
||||
const { results } = message.content.poll.results;
|
||||
const chosenAnswers = results && results.filter((result) => result.isChosen);
|
||||
if (chosenAnswers) {
|
||||
chosenAnswers.forEach((chosenAnswer) => {
|
||||
const chosenAnswerIndex = updatedResults.findIndex((result) => result.option === chosenAnswer.option);
|
||||
if (chosenAnswerIndex >= 0) {
|
||||
updatedPoll.results.results![chosenAnswerIndex].isChosen = true;
|
||||
}
|
||||
});
|
||||
const oldResults = message.content.poll.results;
|
||||
let newResults = oldResults;
|
||||
if (pollUpdate.results?.results) {
|
||||
if (!oldResults.results || !pollUpdate.results.isMin) {
|
||||
newResults = pollUpdate.results;
|
||||
} else if (oldResults.results) {
|
||||
newResults = {
|
||||
...pollUpdate.results,
|
||||
results: pollUpdate.results.results.map((result) => ({
|
||||
...result,
|
||||
isChosen: oldResults.results!.find((r) => r.option === result.option)?.isChosen,
|
||||
})),
|
||||
isMin: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const updatedPoll = { ...message.content.poll, ...pollUpdate, results: newResults };
|
||||
|
||||
setGlobal(updateChatMessage(
|
||||
global,
|
||||
@ -438,8 +439,8 @@ addActionHandler('apiUpdate', (global, actions, update) => {
|
||||
newRecentVoterIds.push(userId);
|
||||
|
||||
options.forEach((option) => {
|
||||
const targetOption = newResults.find((result) => result.option === option);
|
||||
const targetOptionIndex = newResults.findIndex((result) => result.option === option);
|
||||
const targetOption = newResults[targetOptionIndex];
|
||||
const updatedOption: ApiPollResult = targetOption ? { ...targetOption } : { option, votersCount: 0 };
|
||||
|
||||
updatedOption.votersCount += 1;
|
||||
|
||||
@ -332,3 +332,13 @@ addActionHandler('closeGame', (global) => {
|
||||
openedGame: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
addActionHandler('requestConfetti', (global) => {
|
||||
const { animationLevel } = global.settings.byKey;
|
||||
if (animationLevel === 0) return undefined;
|
||||
|
||||
return {
|
||||
...global,
|
||||
lastConfettiTime: Date.now(),
|
||||
};
|
||||
});
|
||||
|
||||
@ -458,6 +458,10 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
|
||||
|
||||
const canSaveGif = message.content.video?.isGif;
|
||||
|
||||
const poll = content.poll;
|
||||
const canRevote = !poll?.summary.closed && !poll?.summary.quiz && poll?.results.results?.some((r) => r.isChosen);
|
||||
const canClosePoll = isOwn && poll && !poll.summary.closed;
|
||||
|
||||
const noOptions = [
|
||||
canReply,
|
||||
canEdit,
|
||||
@ -474,6 +478,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
|
||||
canSelect,
|
||||
canDownload,
|
||||
canSaveGif,
|
||||
canRevote,
|
||||
canClosePoll,
|
||||
].every((ability) => !ability);
|
||||
|
||||
return {
|
||||
@ -493,6 +499,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
|
||||
canSelect,
|
||||
canDownload,
|
||||
canSaveGif,
|
||||
canRevote,
|
||||
canClosePoll,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -596,6 +596,7 @@ export type GlobalState = {
|
||||
bots: Record<string, ApiAttachMenuBot>;
|
||||
};
|
||||
|
||||
lastConfettiTime?: number;
|
||||
urlAuth?: {
|
||||
button?: {
|
||||
chatId: string;
|
||||
@ -685,6 +686,20 @@ export interface ActionPayloads {
|
||||
messageIds: number[];
|
||||
};
|
||||
|
||||
sendPollVote: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
options: string[];
|
||||
};
|
||||
cancelPollVote: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
};
|
||||
closePoll: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
};
|
||||
|
||||
// Media Viewer & Audio Player
|
||||
openMediaViewer: {
|
||||
chatId?: string;
|
||||
@ -909,6 +924,7 @@ export interface ActionPayloads {
|
||||
isQuiz?: boolean;
|
||||
};
|
||||
closePollModal: never;
|
||||
requestConfetti: never;
|
||||
|
||||
openUrl: {
|
||||
url: string;
|
||||
@ -977,7 +993,7 @@ export type NonTypedActionNames = (
|
||||
'addChatMembers' | 'deleteChatMember' | 'openPreviousChat' | 'editChatFolders' | 'toggleIsProtected' |
|
||||
// messages
|
||||
'loadViewportMessages' | 'selectMessage' | 'sendMessage' | 'cancelSendingMessage' | 'pinMessage' | 'deleteMessages' |
|
||||
'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' |
|
||||
'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' |
|
||||
'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' |
|
||||
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
|
||||
'setReplyingToId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
|
||||
|
||||
26
src/hooks/useMultiClick.ts
Normal file
26
src/hooks/useMultiClick.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { useCallback, useRef } from '../lib/teact/teact';
|
||||
|
||||
const CLICK_TIMEOUT = 300;
|
||||
|
||||
export default function useMultiClick(amount: number, callback: NoneToVoidFunction) {
|
||||
const currentAmountRef = useRef(0);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const handleClick = useCallback(() => {
|
||||
currentAmountRef.current++;
|
||||
if (currentAmountRef.current === amount) {
|
||||
currentAmountRef.current = 0;
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
currentAmountRef.current = 0;
|
||||
}, CLICK_TIMEOUT);
|
||||
}, [amount, callback]);
|
||||
|
||||
return handleClick;
|
||||
}
|
||||
@ -201,7 +201,8 @@ $color-message-reaction-own-hover: #b5e0a4;
|
||||
|
||||
--z-lock-screen: 3000;
|
||||
--z-ui-loader-mask: 2000;
|
||||
--z-notification: 1520;
|
||||
--z-notification: 1700;
|
||||
--z-confetti: 1600;
|
||||
--z-right-column: 900;
|
||||
--z-header-menu: 990;
|
||||
--z-header-menu-backdrop: 980;
|
||||
|
||||
@ -224,6 +224,7 @@ export enum SettingsScreens {
|
||||
PasscodeChangePasscodeConfirm,
|
||||
PasscodeTurnOff,
|
||||
PasscodeCongratulations,
|
||||
Experimental,
|
||||
}
|
||||
|
||||
export type StickerSetOrRecent = Pick<ApiStickerSet, (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user