Poll: Add confetti, retract vote, close poll (#1906)

This commit is contained in:
Alexander Zinchuk 2022-07-08 14:59:44 +02:00
parent 25e7545680
commit 918f1cdf7b
30 changed files with 486 additions and 46 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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 {

View File

@ -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,
}: {

View File

@ -125,6 +125,7 @@ export interface ApiPoll {
closeDate?: number;
};
results: {
isMin?: true;
results?: ApiPollResult[];
totalVoters?: number;
recentVoterIds?: string[];

Binary file not shown.

View File

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

View File

@ -16,6 +16,7 @@
font-weight: 500;
margin-left: 1.375rem;
margin-right: auto;
user-select: none;
}
.SearchInput {

View File

@ -142,6 +142,7 @@ const LeftColumn: FC<StateProps> = ({
case SettingsScreens.Privacy:
case SettingsScreens.ActiveSessions:
case SettingsScreens.Language:
case SettingsScreens.Experimental:
setSettingsScreen(SettingsScreens.Main);
return;

View File

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

View 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);

View File

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

View File

@ -0,0 +1,10 @@
.root {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-confetti);
pointer-events: none;
}

View 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));

View File

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

View File

@ -992,7 +992,7 @@ const Composer: FC<OwnProps & StateProps> = ({
<PollModal
isOpen={pollModal.isOpen}
isQuiz={pollModal.isQuiz}
shouldBeAnonimous={isChannel}
shouldBeAnonymous={isChannel}
onClear={closePollModal}
onSend={handlePollSend}
/>

View File

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

View File

@ -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,

View File

@ -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')}

View File

@ -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 {

View File

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

View File

@ -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) {

View File

@ -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,

View File

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

View File

@ -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(),
};
});

View File

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

View File

@ -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' |

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

View File

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

View File

@ -224,6 +224,7 @@ export enum SettingsScreens {
PasscodeChangePasscodeConfirm,
PasscodeTurnOff,
PasscodeCongratulations,
Experimental,
}
export type StickerSetOrRecent = Pick<ApiStickerSet, (