Mini Apps: Allow suggesting user emoji status (#5236)

This commit is contained in:
zubiden 2024-11-27 20:34:21 +04:00 committed by Alexander Zinchuk
parent ba9956f921
commit 0933603cb0
34 changed files with 755 additions and 311 deletions

View File

@ -712,20 +712,20 @@ export function buildInputChatReactions(chatReactions?: ApiChatReactions) {
return new GramJs.ChatReactionsNone(); return new GramJs.ChatReactionsNone();
} }
export function buildInputEmojiStatus(emojiStatus: ApiSticker, expires?: number) { export function buildInputEmojiStatus(emojiStatusId: string, expires?: number) {
if (emojiStatus.id === DEFAULT_STATUS_ICON_ID) { if (emojiStatusId === DEFAULT_STATUS_ICON_ID) {
return new GramJs.EmojiStatusEmpty(); return new GramJs.EmojiStatusEmpty();
} }
if (expires) { if (expires) {
return new GramJs.EmojiStatusUntil({ return new GramJs.EmojiStatusUntil({
documentId: BigInt(emojiStatus.id), documentId: BigInt(emojiStatusId),
until: expires, until: expires,
}); });
} }
return new GramJs.EmojiStatus({ return new GramJs.EmojiStatus({
documentId: BigInt(emojiStatus.id), documentId: BigInt(emojiStatusId),
}); });
} }

View File

@ -2,7 +2,7 @@ import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiChat, ApiPeer, ApiSticker, ApiUser, ApiChat, ApiPeer, ApiUser,
} from '../../types'; } from '../../types';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
@ -304,9 +304,9 @@ export function reportSpam(userOrChat: ApiPeer) {
}); });
} }
export function updateEmojiStatus(emojiStatus: ApiSticker, expires?: number) { export function updateEmojiStatus(emojiStatusId: string, expires?: number) {
return invokeRequest(new GramJs.account.UpdateEmojiStatus({ return invokeRequest(new GramJs.account.UpdateEmojiStatus({
emojiStatus: buildInputEmojiStatus(emojiStatus, expires), emojiStatus: buildInputEmojiStatus(emojiStatusId, expires),
}), { }), {
shouldReturnTrue: true, shouldReturnTrue: true,
}); });

View File

@ -125,6 +125,7 @@ export type ApiNotification = {
disableClickDismiss?: boolean; disableClickDismiss?: boolean;
shouldShowTimer?: boolean; shouldShowTimer?: boolean;
icon?: IconName; icon?: IconName;
customEmojiIconId?: string;
dismissAction?: CallbackAction; dismissAction?: CallbackAction;
}; };

View File

@ -1111,6 +1111,7 @@
"LiveLocationUpdatedMinutesAgo_one" = "updated 1 minute ago"; "LiveLocationUpdatedMinutesAgo_one" = "updated 1 minute ago";
"LiveLocationUpdatedMinutesAgo_other" = "updated {count} minutes ago"; "LiveLocationUpdatedMinutesAgo_other" = "updated {count} minutes ago";
"LiveLocationUpdatedTodayAt" = "updated at {time}"; "LiveLocationUpdatedTodayAt" = "updated at {time}";
"RightNow" = "Just now";
"Seconds_one" = "{count} second"; "Seconds_one" = "{count} second";
"Seconds_other" = "{count} seconds"; "Seconds_other" = "{count} seconds";
"Minutes_one" = "{count} minute"; "Minutes_one" = "{count} minute";
@ -1383,3 +1384,7 @@
"CloseMiniApps" = "Close Mini Apps"; "CloseMiniApps" = "Close Mini Apps";
"DoNotAskAgain" = "Don't ask again"; "DoNotAskAgain" = "Don't ask again";
"PaymentInfoDone" = "Proceed to checkout"; "PaymentInfoDone" = "Proceed to checkout";
"BotSuggestedStatusFor" = "Do you want to set this emoji status suggested by **{bot}** for **{duration}**?";
"BotSuggestedStatus" = "Do you want to set this emoji status suggested by **{bot}**?";
"BotSuggestedStatusTitle" = "Set Emoji Status";
"BotSuggestedStatusUpdated" = "Your emoji status is updated.";

View File

@ -20,6 +20,7 @@ export { default as PremiumMainModal } from '../components/main/premium/PremiumM
export { default as GiveawayModal } from '../components/main/premium/GiveawayModal'; export { default as GiveawayModal } from '../components/main/premium/GiveawayModal';
export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu';
export { default as SuggestedStatusModal } from '../components/modals/suggestedStatus/SuggestedStatusModal';
export { default as BoostModal } from '../components/modals/boost/BoostModal'; export { default as BoostModal } from '../components/modals/boost/BoostModal';
export { default as GiftCodeModal } from '../components/modals/giftcode/GiftCodeModal'; export { default as GiftCodeModal } from '../components/modals/giftcode/GiftCodeModal';
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal'; export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';

View File

@ -8,7 +8,7 @@ import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { getServerTimeOffset } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
@ -198,8 +198,8 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
handleContextMenuClose(); handleContextMenuClose();
onContextMenuClick?.(); onContextMenuClick?.();
setEmojiStatus({ setEmojiStatus({
emojiStatus: sticker, emojiStatusId: sticker.id,
expires: Date.now() / 1000 + duration + getServerTimeOffset(), expires: getServerTime() + duration,
}); });
}); });

View File

@ -2,19 +2,19 @@ import type { TeactNode } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact'; import React, { memo } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global'; import { withGlobal } from '../../../global';
import type { ApiChat, ApiUser } from '../../../api/types'; import type { ApiPeer } from '../../../api/types';
import type { CustomPeer } from '../../../types'; import type { CustomPeer } from '../../../types';
import type { IconName } from '../../../types/icons'; import type { IconName } from '../../../types/icons';
import { getChatTitle, getUserFirstOrLastName } from '../../../global/helpers'; import { isApiPeerChat } from '../../../global/helpers/peers';
import { selectChat, selectUser } from '../../../global/selectors'; import { selectPeer, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { getPeerColorClass } from '../helpers/peerColor'; import { getPeerColorClass } from '../helpers/peerColor';
import renderText from '../helpers/renderText';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import Avatar from '../Avatar'; import Avatar from '../Avatar';
import FullNameTitle from '../FullNameTitle';
import Icon from '../icons/Icon'; import Icon from '../icons/Icon';
import './PickerSelectedItem.scss'; import './PickerSelectedItem.scss';
@ -25,6 +25,7 @@ type OwnProps<T = undefined> = {
// eslint-disable-next-line react/no-unused-prop-types // eslint-disable-next-line react/no-unused-prop-types
forceShowSelf?: boolean; forceShowSelf?: boolean;
customPeer?: CustomPeer; customPeer?: CustomPeer;
mockPeer?: ApiPeer;
icon?: IconName; icon?: IconName;
title?: string; title?: string;
isMinimized?: boolean; isMinimized?: boolean;
@ -32,13 +33,12 @@ type OwnProps<T = undefined> = {
className?: string; className?: string;
fluid?: boolean; fluid?: boolean;
withPeerColors?: boolean; withPeerColors?: boolean;
clickArg: T; clickArg?: T;
onClick: (arg: T) => void; onClick?: (arg: T) => void;
}; };
type StateProps = { type StateProps = {
chat?: ApiChat; peer?: ApiPeer;
user?: ApiUser;
isSavedMessages?: boolean; isSavedMessages?: boolean;
}; };
@ -49,8 +49,8 @@ const PickerSelectedItem = <T,>({
isMinimized, isMinimized,
canClose, canClose,
clickArg, clickArg,
chat, peer,
user, mockPeer,
customPeer, customPeer,
className, className,
fluid, fluid,
@ -60,6 +60,11 @@ const PickerSelectedItem = <T,>({
}: OwnProps<T> & StateProps) => { }: OwnProps<T> & StateProps) => {
const lang = useOldLang(); const lang = useOldLang();
const apiPeer = mockPeer || peer;
const anyPeer = customPeer || apiPeer;
const chat = apiPeer && isApiPeerChat(apiPeer) ? apiPeer : undefined;
let iconElement: TeactNode | undefined; let iconElement: TeactNode | undefined;
let titleText: any; let titleText: any;
@ -71,21 +76,16 @@ const PickerSelectedItem = <T,>({
); );
titleText = title; titleText = title;
} else if (customPeer || user || chat) { } else if (anyPeer) {
iconElement = ( iconElement = (
<Avatar <Avatar
peer={customPeer || user || chat} peer={anyPeer}
size="small" size="small"
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
/> />
); );
const name = (customPeer && (customPeer.title || lang(customPeer.titleKey!))) titleText = title || <FullNameTitle peer={anyPeer} isSavedMessages={isSavedMessages} withEmojiStatus />;
|| (!chat || (user && !isSavedMessages)
? getUserFirstOrLastName(user)
: getChatTitle(lang, chat, isSavedMessages));
titleText = title || (name ? renderText(name) : undefined);
} }
const fullClassName = buildClassName( const fullClassName = buildClassName(
@ -95,13 +95,13 @@ const PickerSelectedItem = <T,>({
isMinimized && 'minimized', isMinimized && 'minimized',
canClose && 'closeable', canClose && 'closeable',
fluid && 'fluid', fluid && 'fluid',
withPeerColors && getPeerColorClass(customPeer || chat || user), withPeerColors && getPeerColorClass(customPeer || peer),
); );
return ( return (
<div <div
className={fullClassName} className={fullClassName}
onClick={() => onClick(clickArg)} onClick={() => onClick?.(clickArg!)}
title={isMinimized ? titleText : undefined} title={isMinimized ? titleText : undefined}
dir={lang.isRtl ? 'rtl' : undefined} dir={lang.isRtl ? 'rtl' : undefined}
> >
@ -126,13 +126,12 @@ export default memo(withGlobal<OwnProps>(
return {}; return {};
} }
const chat = selectChat(global, peerId); const peer = selectPeer(global, peerId);
const user = selectUser(global, peerId); const user = selectUser(global, peerId);
const isSavedMessages = !forceShowSelf && user && user.isSelf; const isSavedMessages = !forceShowSelf && user && user.isSelf;
return { return {
chat, peer,
user,
isSavedMessages, isSavedMessages,
}; };
}, },

View File

@ -6,7 +6,7 @@ import type { ApiEmojiStatus, ApiSticker } from '../../../api/types';
import { EMOJI_STATUS_LOOP_LIMIT } from '../../../config'; import { EMOJI_STATUS_LOOP_LIMIT } from '../../../config';
import { selectUser } from '../../../global/selectors'; import { selectUser } from '../../../global/selectors';
import { getServerTimeOffset } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import useTimeout from '../../../hooks/schedulers/useTimeout'; import useTimeout from '../../../hooks/schedulers/useTimeout';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
@ -36,7 +36,7 @@ const StatusButton: FC<StateProps> = ({ emojiStatus }) => {
const [isStatusPickerOpen, openStatusPicker, closeStatusPicker] = useFlag(false); const [isStatusPickerOpen, openStatusPicker, closeStatusPicker] = useFlag(false);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const delay = emojiStatus?.until ? emojiStatus.until * 1000 - Date.now() + getServerTimeOffset() * 1000 : undefined; const delay = emojiStatus?.until ? (emojiStatus.until - getServerTime()) * 1000 : undefined;
useTimeout(loadCurrentUser, delay); useTimeout(loadCurrentUser, delay);
useEffectWithPrevDeps(([prevEmojiStatus]) => { useEffectWithPrevDeps(([prevEmojiStatus]) => {
@ -48,7 +48,7 @@ const StatusButton: FC<StateProps> = ({ emojiStatus }) => {
const handleEmojiStatusSet = useCallback((sticker: ApiSticker) => { const handleEmojiStatusSet = useCallback((sticker: ApiSticker) => {
markShouldShowEffect(); markShouldShowEffect();
setEmojiStatus({ emojiStatus: sticker }); setEmojiStatus({ emojiStatusId: sticker.id });
}, [markShouldShowEffect, setEmojiStatus]); }, [markShouldShowEffect, setEmojiStatus]);
useTimeout(hideEffect, isEffectShown ? EFFECT_DURATION_MS : undefined); useTimeout(hideEffect, isEffectShown ? EFFECT_DURATION_MS : undefined);

View File

@ -138,6 +138,7 @@
&.has-inline-buttons { &.has-inline-buttons {
.message-content { .message-content {
--border-bottom-left-radius: var(--border-radius-messages-small);
--border-bottom-right-radius: var(--border-radius-messages-small); --border-bottom-right-radius: var(--border-radius-messages-small);
} }
} }

View File

@ -28,6 +28,7 @@ import StarsBalanceModal from './stars/StarsBalanceModal.async';
import StarsPaymentModal from './stars/StarsPaymentModal.async'; import StarsPaymentModal from './stars/StarsPaymentModal.async';
import StarsSubscriptionModal from './stars/subscription/StarsSubscriptionModal.async'; import StarsSubscriptionModal from './stars/subscription/StarsSubscriptionModal.async';
import StarsTransactionInfoModal from './stars/transaction/StarsTransactionModal.async'; import StarsTransactionInfoModal from './stars/transaction/StarsTransactionModal.async';
import SuggestedStatusModal from './suggestedStatus/SuggestedStatusModal.async';
import UrlAuthModal from './urlAuth/UrlAuthModal.async'; import UrlAuthModal from './urlAuth/UrlAuthModal.async';
import WebAppModal from './webApp/WebAppModal.async'; import WebAppModal from './webApp/WebAppModal.async';
@ -57,6 +58,7 @@ type ModalKey = keyof Pick<TabState,
'isGiftRecipientPickerOpen' | 'isGiftRecipientPickerOpen' |
'isWebAppsCloseConfirmationModalOpen' | 'isWebAppsCloseConfirmationModalOpen' |
'giftInfoModal' | 'giftInfoModal' |
'suggestedStatusModal' |
'aboutAdsModal' 'aboutAdsModal'
>; >;
@ -96,6 +98,7 @@ const MODALS: ModalRegistry = {
isGiftRecipientPickerOpen: GiftRecipientPicker, isGiftRecipientPickerOpen: GiftRecipientPicker,
isWebAppsCloseConfirmationModalOpen: WebAppsCloseConfirmationModal, isWebAppsCloseConfirmationModalOpen: WebAppsCloseConfirmationModal,
giftInfoModal: GiftInfoModal, giftInfoModal: GiftInfoModal,
suggestedStatusModal: SuggestedStatusModal,
aboutAdsModal: AboutAdsModal, aboutAdsModal: AboutAdsModal,
}; };
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];

View File

@ -7,12 +7,13 @@ import type { TabState } from '../../../global/types';
import { getChatTitle, isChatAdmin, isChatChannel } from '../../../global/helpers'; import { getChatTitle, isChatAdmin, isChatChannel } from '../../../global/helpers';
import { selectChat, selectChatFullInfo, selectIsCurrentUserPremium } from '../../../global/selectors'; import { selectChat, selectChatFullInfo, selectIsCurrentUserPremium } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatDateInFuture } from '../../../util/dates/dateFormat'; import { formatShortDuration } from '../../../util/dates/dateFormat';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { getBoostProgressInfo } from '../../common/helpers/boostInfo'; import { getBoostProgressInfo } from '../../common/helpers/boostInfo';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
@ -79,7 +80,8 @@ const BoostModal = ({
const isOpen = Boolean(modal); const isOpen = Boolean(modal);
const lang = useOldLang(); const oldLang = useOldLang();
const lang = useLang();
useEffect(() => { useEffect(() => {
if (chat && !chatFullInfo) { if (chat && !chatFullInfo) {
@ -92,16 +94,16 @@ const BoostModal = ({
return undefined; return undefined;
} }
return getChatTitle(lang, chat); return getChatTitle(oldLang, chat);
}, [chat, lang]); }, [chat, oldLang]);
const boostedChatTitle = useMemo(() => { const boostedChatTitle = useMemo(() => {
if (!prevBoostedChat) { if (!prevBoostedChat) {
return undefined; return undefined;
} }
return getChatTitle(lang, prevBoostedChat); return getChatTitle(oldLang, prevBoostedChat);
}, [prevBoostedChat, lang]); }, [prevBoostedChat, oldLang]);
const { const {
isStatusLoaded, isStatusLoaded,
@ -118,7 +120,7 @@ const BoostModal = ({
if (!modal?.boostStatus || !chat) { if (!modal?.boostStatus || !chat) {
return { return {
isStatusLoaded: false, isStatusLoaded: false,
title: lang('Loading'), title: oldLang('Loading'),
}; };
} }
@ -140,23 +142,23 @@ const BoostModal = ({
const hasBoost = hasMyBoost; const hasBoost = hasMyBoost;
const left = lang('BoostsLevel', currentLevel); const left = oldLang('BoostsLevel', currentLevel);
const right = hasNextLevel ? lang('BoostsLevel', currentLevel + 1) : undefined; const right = hasNextLevel ? oldLang('BoostsLevel', currentLevel + 1) : undefined;
const moreBoosts = lang('ChannelBoost.MoreBoosts', remainingBoosts); const moreBoosts = oldLang('ChannelBoost.MoreBoosts', remainingBoosts);
const modalTitle = isChannel ? lang('BoostChannel') : lang('BoostGroup'); const modalTitle = isChannel ? oldLang('BoostChannel') : oldLang('BoostGroup');
const boostsLeftToUnrestrict = (chatFullInfo?.boostsToUnrestrict || 0) - (chatFullInfo?.boostsApplied || 0); const boostsLeftToUnrestrict = (chatFullInfo?.boostsToUnrestrict || 0) - (chatFullInfo?.boostsApplied || 0);
let description: string | undefined; let description: string | undefined;
if (isMaxLevel) { if (isMaxLevel) {
description = lang('BoostsMaxLevelReached'); description = oldLang('BoostsMaxLevelReached');
} else if (boostsLeftToUnrestrict > 0 && !isChatAdmin(chat)) { } else if (boostsLeftToUnrestrict > 0 && !isChatAdmin(chat)) {
const boostTimes = lang('GroupBoost.BoostToUnrestrict.Times', boostsLeftToUnrestrict); const boostTimes = oldLang('GroupBoost.BoostToUnrestrict.Times', boostsLeftToUnrestrict);
description = lang('GroupBoost.BoostToUnrestrict', [boostTimes, chatTitle]); description = oldLang('GroupBoost.BoostToUnrestrict', [boostTimes, chatTitle]);
} else { } else {
description = lang('ChannelBoost.MoreBoostsNeeded.Text', [chatTitle, moreBoosts]); description = oldLang('ChannelBoost.MoreBoostsNeeded.Text', [chatTitle, moreBoosts]);
} }
return { return {
@ -172,7 +174,7 @@ const BoostModal = ({
isBoosted: hasBoost, isBoosted: hasBoost,
canBoostMore: areBoostsInDifferentChannels && !isMaxLevel, canBoostMore: areBoostsInDifferentChannels && !isMaxLevel,
}; };
}, [chat, chatTitle, modal, lang, chatFullInfo, isChannel]); }, [chat, chatTitle, modal, oldLang, chatFullInfo, isChannel]);
const isBoostDisabled = !modal?.myBoosts?.length && isCurrentUserPremium; const isBoostDisabled = !modal?.myBoosts?.length && isCurrentUserPremium;
const isReplacingBoost = boost?.chatId && boost.chatId !== modal?.chatId; const isReplacingBoost = boost?.chatId && boost.chatId !== modal?.chatId;
@ -238,7 +240,7 @@ const BoostModal = ({
/> />
{isBoosted && ( {isBoosted && (
<div className={buildClassName(styles.description, styles.bold)}> <div className={buildClassName(styles.description, styles.bold)}>
{lang('ChannelBoost.YouBoostedChannelText', chatTitle)} {oldLang('ChannelBoost.YouBoostedChannelText', chatTitle)}
</div> </div>
)} )}
<div className={styles.description}> <div className={styles.description}>
@ -249,12 +251,12 @@ const BoostModal = ({
{canBoostMore ? ( {canBoostMore ? (
<> <>
<Icon name="boost" /> <Icon name="boost" />
{lang(isChannel ? 'ChannelBoost.BoostChannel' : 'GroupBoost.BoostGroup')} {oldLang(isChannel ? 'ChannelBoost.BoostChannel' : 'GroupBoost.BoostGroup')}
</> </>
) : lang('OK')} ) : oldLang('OK')}
</Button> </Button>
<Button isText className="confirm-dialog-button" onClick={handleCloseClick}> <Button isText className="confirm-dialog-button" onClick={handleCloseClick}>
{lang('Cancel')} {oldLang('Cancel')}
</Button> </Button>
</div> </div>
</> </>
@ -286,14 +288,16 @@ const BoostModal = ({
<Avatar peer={chat} size="large" /> <Avatar peer={chat} size="large" />
</div> </div>
<div> <div>
{renderText(lang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'])} {renderText(
oldLang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'],
)}
</div> </div>
<div className="dialog-buttons"> <div className="dialog-buttons">
<Button isText className="confirm-dialog-button" onClick={handleApplyBoost}> <Button isText className="confirm-dialog-button" onClick={handleApplyBoost}>
{lang('Replace')} {oldLang('Replace')}
</Button> </Button>
<Button isText className="confirm-dialog-button" onClick={closeReplaceModal}> <Button isText className="confirm-dialog-button" onClick={closeReplaceModal}>
{lang('Cancel')} {oldLang('Cancel')}
</Button> </Button>
</div> </div>
</Modal> </Modal>
@ -302,15 +306,15 @@ const BoostModal = ({
<ConfirmDialog <ConfirmDialog
isOpen={isWaitDialogOpen} isOpen={isWaitDialogOpen}
isOnlyConfirm isOnlyConfirm
confirmLabel={lang('OK')} confirmLabel={oldLang('OK')}
title={lang('ChannelBoost.Error.BoostTooOftenTitle')} title={oldLang('ChannelBoost.Error.BoostTooOftenTitle')}
onClose={closeWaitDialog} onClose={closeWaitDialog}
confirmHandler={closeWaitDialog} confirmHandler={closeWaitDialog}
> >
{renderText( {renderText(
lang( oldLang(
'ChannelBoost.Error.BoostTooOftenText', 'ChannelBoost.Error.BoostTooOftenText',
formatDateInFuture(lang, getServerTime(), boost!.cooldownUntil), formatShortDuration(lang, boost!.cooldownUntil - getServerTime()),
), ),
['simple_markdown', 'emoji'], ['simple_markdown', 'emoji'],
)} )}
@ -319,12 +323,12 @@ const BoostModal = ({
{!isCurrentUserPremium && ( {!isCurrentUserPremium && (
<ConfirmDialog <ConfirmDialog
isOpen={isPremiumDialogOpen} isOpen={isPremiumDialogOpen}
confirmLabel={lang('Common.Yes')} confirmLabel={oldLang('Common.Yes')}
title={lang('PremiumNeeded')} title={oldLang('PremiumNeeded')}
onClose={closePremiumDialog} onClose={closePremiumDialog}
confirmHandler={handleProceedPremium} confirmHandler={handleProceedPremium}
> >
{renderText(lang('PremiumNeededForBoosting'), ['simple_markdown', 'emoji'])} {renderText(oldLang('PremiumNeededForBoosting'), ['simple_markdown', 'emoji'])}
</ConfirmDialog> </ConfirmDialog>
)} )}
</Modal> </Modal>

View File

@ -10,6 +10,7 @@ import buildClassName from '../../../../util/buildClassName';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat'; import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format'; import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format';
import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer'; import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer';
import { getServerTime } from '../../../../util/serverTime';
import { formatInteger } from '../../../../util/textFormat'; import { formatInteger } from '../../../../util/textFormat';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
@ -67,7 +68,7 @@ const GiftInfoModal = ({
const canUpdate = Boolean(userGift?.fromId && userGift.messageId); const canUpdate = Boolean(userGift?.fromId && userGift.messageId);
const isSender = userGift?.fromId === currentUserId; const isSender = userGift?.fromId === currentUserId;
const canConvertDifference = (userGift && starGiftMaxConvertPeriod && ( const canConvertDifference = (userGift && starGiftMaxConvertPeriod && (
userGift.date + starGiftMaxConvertPeriod - Date.now() / 1000 userGift.date + starGiftMaxConvertPeriod - getServerTime()
)) || 0; )) || 0;
const conversionLeft = Math.ceil(canConvertDifference / 60 / 60 / 24); const conversionLeft = Math.ceil(canConvertDifference / 60 / 60 / 24);

View File

@ -0,0 +1,18 @@
import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact';
import type { OwnProps } from './SuggestedStatusModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const SuggestedStatusModalAsync: FC<OwnProps> = (props) => {
const { modal } = props;
const SuggestedStatusModal = useModuleLoader(Bundles.Extra, 'SuggestedStatusModal', !modal);
// eslint-disable-next-line react/jsx-props-no-spreading
return SuggestedStatusModal ? <SuggestedStatusModal {...props} /> : undefined;
};
export default SuggestedStatusModalAsync;

View File

@ -0,0 +1,20 @@
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.topEmoji {
--custom-emoji-size: 6rem;
}
.description {
text-align: center;
margin-bottom: 0;
}
.title {
font-size: 1.5rem;
text-align: center;
}

View File

@ -0,0 +1,145 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiUser } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers';
import { selectUser } from '../../../global/selectors';
import { formatShortDuration } from '../../../util/dates/dateFormat';
import { getServerTime } from '../../../util/serverTime';
import { REM } from '../../common/helpers/mediaDimensions';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import CustomEmoji from '../../common/CustomEmoji';
import PickerSelectedItem from '../../common/pickers/PickerSelectedItem';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import styles from './SuggestedStatusModal.module.scss';
export type OwnProps = {
modal: TabState['suggestedStatusModal'];
};
type StateProps = {
bot?: ApiUser;
currentUser?: ApiUser;
};
const CUSTOM_EMOJI_SIZE = 6 * REM;
const SuggestedStatusModal = ({ modal, currentUser, bot }: OwnProps & StateProps) => {
const { setEmojiStatus, closeSuggestedStatusModal, sendWebAppEvent } = getActions();
const lang = useLang();
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
const mockPeerWithStatus = useMemo(() => {
if (!currentUser || !renderingModal) return undefined;
return {
...currentUser,
emojiStatus: {
documentId: renderingModal.customEmojiId,
},
} satisfies ApiUser;
}, [currentUser, renderingModal]);
const description = useMemo(() => {
if (!renderingModal || !bot) return undefined;
const botName = getUserFullName(bot);
if (renderingModal.duration) {
return lang('BotSuggestedStatusFor', {
bot: botName,
duration: formatShortDuration(lang, renderingModal.duration),
}, {
withNodes: true,
withMarkdown: true,
});
}
return lang('BotSuggestedStatus', { bot: botName }, { withNodes: true, withMarkdown: true });
}, [bot, lang, renderingModal]);
const handleClose = useLastCallback(() => {
const webAppKey = renderingModal?.webAppKey;
if (webAppKey) {
sendWebAppEvent({
webAppKey,
event: {
eventType: 'emoji_status_failed',
eventData: {
error: 'USER_DECLINED',
},
},
});
}
closeSuggestedStatusModal();
});
const handleSetStatus = useLastCallback(() => {
if (!renderingModal) return;
const expires = renderingModal.duration ? getServerTime() + renderingModal.duration : undefined;
setEmojiStatus({
referrerWebAppKey: renderingModal.webAppKey,
emojiStatusId: renderingModal.customEmojiId,
expires,
});
closeSuggestedStatusModal();
});
return (
<Modal
isOpen={isOpen}
contentClassName={styles.content}
hasAbsoluteCloseButton
isSlim
onClose={handleClose}
>
{renderingModal && (
<CustomEmoji
className={styles.topEmoji}
documentId={renderingModal.customEmojiId}
size={CUSTOM_EMOJI_SIZE}
loopLimit={1}
forceAlways
/>
)}
<div>
<h3 className={styles.title}>{lang('BotSuggestedStatusTitle')}</h3>
<p className={styles.description}>{description}</p>
</div>
{mockPeerWithStatus && (
<PickerSelectedItem
mockPeer={mockPeerWithStatus}
/>
)}
<Button size="smaller" onClick={handleSetStatus}>
{lang('GeneralConfirm')}
</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const currentUser = selectUser(global, global.currentUserId!);
const bot = modal?.botId ? selectUser(global, modal.botId) : undefined;
return {
currentUser,
bot,
};
},
)(SuggestedStatusModal));

View File

@ -248,9 +248,10 @@ const WebAppModal: FC<OwnProps & StateProps> = ({
const handleToggleClick = useLastCallback(() => { const handleToggleClick = useLastCallback(() => {
if (attachBot) { if (attachBot) {
const key = getWebAppKey(activeWebApp!);
updateWebApp({ updateWebApp({
webApp: { key,
...activeWebApp!, update: {
isRemoveModalOpen: true, isRemoveModalOpen: true,
}, },
}); });

View File

@ -140,13 +140,8 @@ const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
const isActive = (activeWebApp && webApp) && activeWebAppKey === webAppKey; const isActive = (activeWebApp && webApp) && activeWebAppKey === webAppKey;
const updateCurrentWebApp = useLastCallback((updatedPartialWebApp: Partial<WebApp>) => { const updateCurrentWebApp = useLastCallback((updatedPartialWebApp: Partial<WebApp>) => {
if (!webApp) return; if (!webAppKey) return;
const updatedWebApp = { updateWebApp({ key: webAppKey, update: updatedPartialWebApp });
...webApp,
...updatedPartialWebApp,
};
webApp = updatedWebApp;
updateWebApp({ webApp: updatedWebApp });
}); });
useEffect(() => { useEffect(() => {

View File

@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact'; import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { WebApp } from '../../../../global/types'; import type { WebApp } from '../../../../global/types';
import type { WebAppInboundEvent, WebAppOutboundEvent } from '../../../../types/webapp'; import type { WebAppInboundEvent, WebAppOutboundEvent } from '../../../../types/webapp';
import { getWebAppKey } from '../../../../global/helpers';
import { extractCurrentThemeParams } from '../../../../util/themeStyle'; import { extractCurrentThemeParams } from '../../../../util/themeStyle';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
@ -44,6 +46,8 @@ const useWebAppFrame = (
setWebAppPaymentSlug, setWebAppPaymentSlug,
openInvoice, openInvoice,
closeWebApp, closeWebApp,
openSuggestedStatusModal,
updateWebApp,
} = getActions(); } = getActions();
const isReloadSupported = useRef<boolean>(false); const isReloadSupported = useRef<boolean>(false);
@ -146,7 +150,10 @@ const useWebAppFrame = (
} }
if (eventType === 'web_app_close') { if (eventType === 'web_app_close') {
if (webApp) closeWebApp({ webApp, skipClosingConfirmation: true }); if (webApp) {
const key = getWebAppKey(webApp);
closeWebApp({ key, skipClosingConfirmation: true });
}
} }
if (eventType === 'web_app_request_viewport') { if (eventType === 'web_app_request_viewport') {
@ -214,6 +221,51 @@ const useWebAppFrame = (
}); });
} }
if (eventType === 'web_app_set_emoji_status') {
const { custom_emoji_id, duration } = eventData;
if (!custom_emoji_id || typeof custom_emoji_id !== 'string') {
sendEvent({
eventType: 'emoji_status_failed',
eventData: {
error: 'SUGGESTED_EMOJI_INVALID',
},
});
return;
}
if (duration) {
try {
BigInt(duration);
} catch (e) {
sendEvent({
eventType: 'emoji_status_failed',
eventData: {
error: 'DURATION_INVALID',
},
});
return;
}
}
if (!webApp) {
sendEvent({
eventType: 'emoji_status_failed',
eventData: {
error: 'UNKNOWN_ERROR',
},
});
return;
}
openSuggestedStatusModal({
webAppKey: getWebAppKey(webApp),
customEmojiId: custom_emoji_id,
duration: Number(duration),
botId: webApp.botId,
});
}
onEvent(data); onEvent(data);
} catch (err) { } catch (err) {
// Ignore other messages // Ignore other messages
@ -231,6 +283,21 @@ const useWebAppFrame = (
sendViewport(isResizing); sendViewport(isResizing);
}, [sendViewport, windowSize]); }, [sendViewport, windowSize]);
useEffect(() => {
if (!webApp?.plannedEvents?.length) return;
const events = webApp.plannedEvents;
events.forEach((event) => {
sendEvent(event);
});
updateWebApp({
key: getWebAppKey(webApp),
update: {
plannedEvents: [],
},
});
}, [sendEvent, webApp]);
useEffect(() => { useEffect(() => {
window.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage);

View File

@ -165,7 +165,6 @@
.modal-content, .modal-content,
.modal-content > p { .modal-content > p {
unicode-bidi: plaintext; unicode-bidi: plaintext;
text-align: initial;
} }
.modal-about { .modal-about {

View File

@ -46,6 +46,13 @@
.notification-icon { .notification-icon {
font-size: 1.75rem; font-size: 1.75rem;
}
.notification-emoji-icon {
--custom-emoji-size: 1.75rem;
}
.notification-icon, .notification-emoji-icon {
margin-inline-end: 0.75rem; margin-inline-end: 0.75rem;
} }

View File

@ -13,12 +13,14 @@ import { isLangFnParam } from '../../util/localization/types';
import { ANIMATION_END_DELAY } from '../../config'; import { ANIMATION_END_DELAY } from '../../config';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import captureEscKeyListener from '../../util/captureEscKeyListener'; import captureEscKeyListener from '../../util/captureEscKeyListener';
import { REM } from '../common/helpers/mediaDimensions';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated';
import CustomEmoji from '../common/CustomEmoji';
import Icon from '../common/icons/Icon'; import Icon from '../common/icons/Icon';
import Button from './Button'; import Button from './Button';
import Portal from './Portal'; import Portal from './Portal';
@ -32,6 +34,7 @@ type OwnProps = {
const DEFAULT_DURATION = 3000; const DEFAULT_DURATION = 3000;
const ANIMATION_DURATION = 150; const ANIMATION_DURATION = 150;
const CUSTOM_EMOJI_SIZE = 1.75 * REM;
const Notification: FC<OwnProps> = ({ const Notification: FC<OwnProps> = ({
notification, notification,
@ -51,6 +54,7 @@ const Notification: FC<OwnProps> = ({
dismissAction, dismissAction,
duration = DEFAULT_DURATION, duration = DEFAULT_DURATION,
icon, icon,
customEmojiIconId,
shouldShowTimer, shouldShowTimer,
title, title,
containerSelector, containerSelector,
@ -155,7 +159,16 @@ const Notification: FC<OwnProps> = ({
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
<Icon name={icon || 'info-filled'} className="notification-icon" /> {customEmojiIconId ? (
<CustomEmoji
className="notification-emoji-icon"
forceAlways
size={CUSTOM_EMOJI_SIZE}
documentId={customEmojiIconId}
/>
) : (
<Icon name={icon || 'info-filled'} className="notification-icon" />
)}
<div className="content"> <div className="content">
{renderedTitle && ( {renderedTitle && (
<div className="notification-title">{renderedTitle}</div> <div className="notification-title">{renderedTitle}</div>

View File

@ -18,6 +18,7 @@ import './api/statistics';
import './api/stories'; import './api/stories';
import './ui/initial'; import './ui/initial';
import './ui/chats'; import './ui/chats';
import './ui/bots';
import './ui/messages'; import './ui/messages';
import './ui/globalSearch'; import './ui/globalSearch';
import './ui/middleSearch'; import './ui/middleSearch';

View File

@ -4,7 +4,7 @@ import type {
ActionReturnType, GlobalState, TabArgs, WebApp, ActionReturnType, GlobalState, TabArgs, WebApp,
} from '../../types'; } from '../../types';
import { import {
type ApiChat, type ApiChatType, type ApiContact, type ApiInputMessageReplyInfo, type ApiPeer, type ApiUrlAuthResult, type ApiChat, type ApiContact, type ApiInputMessageReplyInfo, type ApiPeer, type ApiUrlAuthResult,
MAIN_THREAD_ID, MAIN_THREAD_ID,
} from '../../../api/types'; } from '../../../api/types';
import { ManagementProgress } from '../../../types'; import { ManagementProgress } from '../../../types';
@ -30,10 +30,9 @@ import {
} from '../../reducers'; } from '../../reducers';
import { import {
activateWebAppIfOpen, activateWebAppIfOpen,
addWebAppToOpenList, clearOpenedWebApps, hasOpenedMoreThanOneWebApps, addWebAppToOpenList,
hasOpenedWebApps, removeActiveWebAppFromOpenList, removeWebAppFromOpenList, replaceInlineBotSettings,
replaceInlineBotSettings, replaceInlineBotsIsLoading, replaceInlineBotsIsLoading,
replaceIsWebAppModalOpen, replaceWebAppModalState, updateWebApp,
} from '../../reducers/bots'; } from '../../reducers/bots';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { import {
@ -692,18 +691,6 @@ addActionHandler('loadPreviewMedias', async (global, actions, payload): Promise<
} }
}); });
addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType => {
const {
webApp, tabId = getCurrentTabId(),
} = payload;
if (webApp) {
global = getGlobal();
global = addWebAppToOpenList(global, webApp, true, true, tabId);
setGlobal(global);
}
});
addActionHandler('openWebAppsCloseConfirmationModal', (global, actions, payload): ActionReturnType => { addActionHandler('openWebAppsCloseConfirmationModal', (global, actions, payload): ActionReturnType => {
const { const {
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
@ -872,143 +859,6 @@ addActionHandler('sendWebViewData', (global, actions, payload): ActionReturnType
}); });
}); });
addActionHandler('updateWebApp', (global, actions, payload): ActionReturnType => {
const {
webApp, tabId = getCurrentTabId(),
} = payload;
return updateWebApp(global, webApp, tabId);
});
addActionHandler('closeActiveWebApp', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
global = removeActiveWebAppFromOpenList(global, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('openMoreAppsTab', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
webApps: {
...tabState.webApps,
activeWebApp: undefined,
isMoreAppsTabActive: true,
},
}, tabId);
return global;
});
addActionHandler('closeMoreAppsTab', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const openedWebApps = tabState.webApps.openedWebApps;
const openedWebAppsValues = Object.values(openedWebApps);
const openedWebAppsCount = openedWebAppsValues.length;
global = updateTabState(global, {
webApps: {
...tabState.webApps,
isMoreAppsTabActive: false,
activeWebApp: openedWebAppsCount ? openedWebAppsValues[openedWebAppsCount - 1] : undefined,
isModalOpen: openedWebAppsCount > 0,
},
}, tabId);
return global;
});
addActionHandler('closeWebApp', (global, actions, payload): ActionReturnType => {
const { webApp, skipClosingConfirmation, tabId = getCurrentTabId() } = payload || {};
global = removeWebAppFromOpenList(global, webApp, skipClosingConfirmation, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('closeWebAppModal', (global, actions, payload): ActionReturnType => {
const { shouldSkipConfirmation, tabId = getCurrentTabId() } = payload || {};
const shouldShowConfirmation = !shouldSkipConfirmation
&& !global.settings.byKey.shouldSkipWebAppCloseConfirmation && hasOpenedMoreThanOneWebApps(global, tabId);
if (shouldShowConfirmation) {
actions.openWebAppsCloseConfirmationModal({ tabId });
return global;
}
global = clearOpenedWebApps(global, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('changeWebAppModalState', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const newModalState = tabState.webApps.modalState === 'maximized' ? 'minimized' : 'maximized';
return replaceWebAppModalState(global, newModalState, tabId);
});
addActionHandler('setWebAppPaymentSlug', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
const activeWebApp = tabState.webApps.activeWebApp;
if (!activeWebApp?.url) return undefined;
const updatedApp = {
...activeWebApp,
slug: payload.slug,
};
return updateWebApp(global, updatedApp, tabId);
});
addActionHandler('cancelBotTrustRequest', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
botTrustRequest: undefined,
}, tabId);
});
addActionHandler('markBotTrusted', (global, actions, payload): ActionReturnType => {
const { botId, isWriteAllowed, tabId = getCurrentTabId() } = payload;
const { trustedBotIds } = global;
const newTrustedBotIds = new Set(trustedBotIds);
newTrustedBotIds.add(botId);
global = {
...global,
trustedBotIds: Array.from(newTrustedBotIds),
};
const tabState = selectTabState(global, tabId);
if (tabState.botTrustRequest?.onConfirm) {
const { action, payload: callbackPayload } = tabState.botTrustRequest.onConfirm;
// @ts-ignore
actions[action]({
...(callbackPayload as {}),
isWriteAllowed,
});
}
global = updateTabState(global, {
botTrustRequest: undefined,
}, tabId);
setGlobal(global);
});
addActionHandler('loadAttachBots', async (global): Promise<void> => { addActionHandler('loadAttachBots', async (global): Promise<void> => {
await loadAttachBots(global); await loadAttachBots(global);
@ -1150,50 +1000,6 @@ addActionHandler('confirmAttachBotInstall', async (global, actions, payload): Pr
} }
}); });
addActionHandler('cancelAttachBotInstall', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
requestedAttachBotInstall: undefined,
}, tabId);
});
addActionHandler('requestAttachBotInChat', (global, actions, payload): ActionReturnType => {
const {
bot, filter, startParam, tabId = getCurrentTabId(),
} = payload;
const currentChatId = selectCurrentMessageList(global, tabId)?.chatId;
const supportedFilters = bot.attachMenuPeerTypes?.filter((type): type is ApiChatType => (
type !== 'self' && filter.includes(type)
));
if (!supportedFilters?.length) {
actions.callAttachBot({
chatId: currentChatId || bot.id,
bot,
startParam,
tabId,
});
return;
}
global = updateTabState(global, {
requestedAttachBotInChat: {
bot,
filter: supportedFilters,
startParam,
},
}, tabId);
setGlobal(global);
});
addActionHandler('cancelAttachBotInChat', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
requestedAttachBotInChat: undefined,
}, tabId);
});
addActionHandler('requestBotUrlAuth', async (global, actions, payload): Promise<void> => { addActionHandler('requestBotUrlAuth', async (global, actions, payload): Promise<void> => {
const { const {
chatId, buttonId, messageId, url, tabId = getCurrentTabId(), chatId, buttonId, messageId, url, tabId = getCurrentTabId(),

View File

@ -29,9 +29,11 @@ import {
updateUserSearch, updateUserSearch,
updateUserSearchFetchingStatus, updateUserSearchFetchingStatus,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import { import {
selectChat, selectChat,
selectChatFullInfo, selectChatFullInfo,
selectIsCurrentUserPremium,
selectPeer, selectPeer,
selectPeerPhotos, selectPeerPhotos,
selectTabState, selectTabState,
@ -388,10 +390,62 @@ addActionHandler('reportSpam', (global, actions, payload): ActionReturnType => {
void callApi('reportSpam', peer); void callApi('reportSpam', peer);
}); });
addActionHandler('setEmojiStatus', (global, actions, payload): ActionReturnType => { addActionHandler('setEmojiStatus', async (global, actions, payload): Promise<void> => {
const { emojiStatus, expires } = payload; const {
emojiStatusId, referrerWebAppKey, expires, tabId = getCurrentTabId(),
} = payload;
void callApi('updateEmojiStatus', emojiStatus, expires); const isCurrentUserPremium = selectIsCurrentUserPremium(global);
if (!isCurrentUserPremium) {
if (referrerWebAppKey) {
actions.sendWebAppEvent({
webAppKey: referrerWebAppKey,
event: {
eventType: 'emoji_status_failed',
eventData: {
error: 'USER_DECLINED',
},
},
tabId,
});
}
actions.openPremiumModal({ initialSection: 'emoji_status', tabId });
return;
}
const result = await callApi('updateEmojiStatus', emojiStatusId, expires);
if (referrerWebAppKey) {
if (!result) {
actions.sendWebAppEvent({
webAppKey: referrerWebAppKey,
event: {
eventType: 'emoji_status_failed',
eventData: {
error: 'SERVER_ERROR',
},
},
tabId,
});
return;
}
actions.sendWebAppEvent({
webAppKey: referrerWebAppKey,
event: {
eventType: 'emoji_status_set',
},
tabId,
});
actions.showNotification({
message: {
key: 'BotSuggestedStatusUpdated',
},
customEmojiIconId: emojiStatusId,
tabId,
});
}
}); });
addActionHandler('saveCloseFriends', async (global, actions, payload): Promise<void> => { addActionHandler('saveCloseFriends', async (global, actions, payload): Promise<void> => {
@ -418,3 +472,39 @@ addActionHandler('saveCloseFriends', async (global, actions, payload): Promise<v
}); });
setGlobal(global); setGlobal(global);
}); });
addActionHandler('openSuggestedStatusModal', async (global, actions, payload): Promise<void> => {
const {
customEmojiId, duration, botId, webAppKey, tabId = getCurrentTabId(),
} = payload;
const customEmoji = await callApi('fetchCustomEmoji', {
documentId: [customEmojiId],
});
if (!customEmoji?.[0]) {
if (webAppKey) {
actions.sendWebAppEvent({
webAppKey,
event: {
eventType: 'emoji_status_failed',
eventData: {
error: 'SUGGESTED_EMOJI_INVALID',
},
},
tabId,
});
}
return;
}
global = getGlobal();
global = updateTabState(global, {
suggestedStatusModal: {
customEmojiId,
duration,
webAppKey,
botId,
},
}, tabId);
setGlobal(global);
});

View File

@ -1,8 +1,22 @@
import type { ApiChatType } from '../../../api/types';
import type { ActionReturnType } from '../../types'; import type { ActionReturnType } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { getWebAppKey } from '../../helpers';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { addWebAppToOpenList } from '../../reducers/bots'; import {
addWebAppToOpenList,
clearOpenedWebApps,
hasOpenedMoreThanOneWebApps,
hasOpenedWebApps,
removeActiveWebAppFromOpenList,
removeWebAppFromOpenList,
replaceIsWebAppModalOpen,
replaceWebAppModalState,
updateWebApp,
} from '../../reducers/bots';
import { updateTabState } from '../../reducers/tabs';
import { selectCurrentMessageList, selectTabState, selectWebApp } from '../../selectors';
addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType => { addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType => {
const { const {
@ -15,3 +29,199 @@ addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType =
global = addWebAppToOpenList(global, webApp, true, true, tabId); global = addWebAppToOpenList(global, webApp, true, true, tabId);
setGlobal(global); setGlobal(global);
}); });
addActionHandler('updateWebApp', (global, actions, payload): ActionReturnType => {
const {
key, update, tabId = getCurrentTabId(),
} = payload;
return updateWebApp(global, key, update, tabId);
});
addActionHandler('closeActiveWebApp', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
global = removeActiveWebAppFromOpenList(global, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('openMoreAppsTab', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
webApps: {
...tabState.webApps,
activeWebApp: undefined,
isMoreAppsTabActive: true,
},
}, tabId);
return global;
});
addActionHandler('closeMoreAppsTab', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const openedWebApps = tabState.webApps.openedWebApps;
const openedWebAppsValues = Object.values(openedWebApps);
const openedWebAppsCount = openedWebAppsValues.length;
global = updateTabState(global, {
webApps: {
...tabState.webApps,
isMoreAppsTabActive: false,
activeWebApp: openedWebAppsCount ? openedWebAppsValues[openedWebAppsCount - 1] : undefined,
isModalOpen: openedWebAppsCount > 0,
},
}, tabId);
return global;
});
addActionHandler('closeWebApp', (global, actions, payload): ActionReturnType => {
const { key, skipClosingConfirmation, tabId = getCurrentTabId() } = payload || {};
global = removeWebAppFromOpenList(global, key, skipClosingConfirmation, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('closeWebAppModal', (global, actions, payload): ActionReturnType => {
const { shouldSkipConfirmation, tabId = getCurrentTabId() } = payload || {};
const shouldShowConfirmation = !shouldSkipConfirmation
&& !global.settings.byKey.shouldSkipWebAppCloseConfirmation && hasOpenedMoreThanOneWebApps(global, tabId);
if (shouldShowConfirmation) {
actions.openWebAppsCloseConfirmationModal({ tabId });
return global;
}
global = clearOpenedWebApps(global, tabId);
if (!hasOpenedWebApps(global, tabId)) return replaceIsWebAppModalOpen(global, false, tabId);
return global;
});
addActionHandler('changeWebAppModalState', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const tabState = selectTabState(global, tabId);
const newModalState = tabState.webApps.modalState === 'maximized' ? 'minimized' : 'maximized';
return replaceWebAppModalState(global, newModalState, tabId);
});
addActionHandler('setWebAppPaymentSlug', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);
const activeWebApp = tabState.webApps.activeWebApp;
if (!activeWebApp?.url) return undefined;
const key = getWebAppKey(activeWebApp);
return updateWebApp(global, key, { slug: payload.slug }, tabId);
});
addActionHandler('cancelBotTrustRequest', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
botTrustRequest: undefined,
}, tabId);
});
addActionHandler('markBotTrusted', (global, actions, payload): ActionReturnType => {
const { botId, isWriteAllowed, tabId = getCurrentTabId() } = payload;
const { trustedBotIds } = global;
const newTrustedBotIds = new Set(trustedBotIds);
newTrustedBotIds.add(botId);
global = {
...global,
trustedBotIds: Array.from(newTrustedBotIds),
};
const tabState = selectTabState(global, tabId);
if (tabState.botTrustRequest?.onConfirm) {
const { action, payload: callbackPayload } = tabState.botTrustRequest.onConfirm;
// @ts-ignore
actions[action]({
...(callbackPayload as {}),
isWriteAllowed,
});
}
global = updateTabState(global, {
botTrustRequest: undefined,
}, tabId);
setGlobal(global);
});
addActionHandler('sendWebAppEvent', (global, actions, payload): ActionReturnType => {
const { event, webAppKey, tabId = getCurrentTabId() } = payload;
const webApp = selectWebApp(global, webAppKey, tabId);
if (!webApp) return global;
const newPlannedEvents = webApp.plannedEvents ? [...webApp.plannedEvents, event] : [event];
actions.updateWebApp({
key: webAppKey,
update: {
plannedEvents: newPlannedEvents,
},
tabId,
});
return global;
});
addActionHandler('cancelAttachBotInstall', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
requestedAttachBotInstall: undefined,
}, tabId);
});
addActionHandler('requestAttachBotInChat', (global, actions, payload): ActionReturnType => {
const {
bot, filter, startParam, tabId = getCurrentTabId(),
} = payload;
const currentChatId = selectCurrentMessageList(global, tabId)?.chatId;
const supportedFilters = bot.attachMenuPeerTypes?.filter((type): type is ApiChatType => (
type !== 'self' && filter.includes(type)
));
if (!supportedFilters?.length) {
actions.callAttachBot({
chatId: currentChatId || bot.id,
bot,
startParam,
tabId,
});
return;
}
global = updateTabState(global, {
requestedAttachBotInChat: {
bot,
filter: supportedFilters,
startParam,
},
}, tabId);
setGlobal(global);
});
addActionHandler('cancelAttachBotInChat', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
requestedAttachBotInChat: undefined,
}, tabId);
});

View File

@ -9,7 +9,7 @@ addActionHandler('setUserSearchQuery', (global, actions, payload): ActionReturnT
const { const {
query, query,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload!; } = payload;
return updateUserSearch(global, { return updateUserSearch(global, {
globalUserIds: undefined, globalUserIds: undefined,
@ -20,7 +20,7 @@ addActionHandler('setUserSearchQuery', (global, actions, payload): ActionReturnT
}); });
addActionHandler('openAddContactDialog', (global, actions, payload): ActionReturnType => { addActionHandler('openAddContactDialog', (global, actions, payload): ActionReturnType => {
const { userId, tabId = getCurrentTabId() } = payload!; const { userId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, { return updateTabState(global, {
newContact: { userId }, newContact: { userId },
@ -42,3 +42,11 @@ addActionHandler('closeNewContactDialog', (global, actions, payload): ActionRetu
return closeNewContactDialog(global, tabId); return closeNewContactDialog(global, tabId);
}); });
addActionHandler('closeSuggestedStatusModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
suggestedStatusModal: undefined,
}, tabId);
});

View File

@ -20,7 +20,7 @@ export function convertToApiChatType(type: string): ApiChatType | undefined {
export function getWebAppKey(webApp: Partial<WebApp>) { export function getWebAppKey(webApp: Partial<WebApp>) {
if (webApp.requestUrl) return webApp.requestUrl; if (webApp.requestUrl) return webApp.requestUrl;
if (webApp.appName) return `${webApp.botId}?appName=${webApp.appName}`; if (webApp.appName) return `${webApp.botId}?appName=${webApp.appName}`;
return webApp.botId; return webApp.botId!;
} }
export function isSystemBot(botId: string) { export function isSystemBot(botId: string) {

View File

@ -37,20 +37,19 @@ export function replaceInlineBotsIsLoading<T extends GlobalState>(
} }
export function updateWebApp <T extends GlobalState>( export function updateWebApp <T extends GlobalState>(
global: T, webApp: Partial<WebApp>, global: T, key: string, webAppUpdate: Partial<WebApp>,
...[tabId = getCurrentTabId()]: TabArgs<T> ...[tabId = getCurrentTabId()]: TabArgs<T>
): T { ): T {
const currentTabState = selectTabState(global, tabId); const currentTabState = selectTabState(global, tabId);
const openedWebApps = currentTabState.webApps.openedWebApps; const openedWebApps = currentTabState.webApps.openedWebApps;
const key = webApp && getWebAppKey(webApp); const originalWebApp = openedWebApps[key];
const originalWebApp = key ? openedWebApps[key] : undefined;
if (!originalWebApp) return global; if (!originalWebApp) return global;
const updatedValue = { const updatedValue = {
...originalWebApp, ...originalWebApp,
...webApp, ...webAppUpdate,
}; };
const updatedWebAppKey = getWebAppKey(updatedValue); const updatedWebAppKey = getWebAppKey(updatedValue);
@ -143,18 +142,22 @@ export function removeActiveWebAppFromOpenList<T extends GlobalState>(
if (!currentTabState.webApps.activeWebApp) return global; if (!currentTabState.webApps.activeWebApp) return global;
return removeWebAppFromOpenList(global, currentTabState.webApps.activeWebApp, false, tabId); const key = getWebAppKey(currentTabState.webApps.activeWebApp);
return removeWebAppFromOpenList(global, key, false, tabId);
} }
export function removeWebAppFromOpenList<T extends GlobalState>( export function removeWebAppFromOpenList<T extends GlobalState>(
global: T, webApp: WebApp, skipClosingConfirmation?: boolean, global: T, key: string, skipClosingConfirmation?: boolean,
...[tabId = getCurrentTabId()]: TabArgs<T> ...[tabId = getCurrentTabId()]: TabArgs<T>
): T { ): T {
const currentTabState = selectTabState(global, tabId); const currentTabState = selectTabState(global, tabId);
const openedWebApps = currentTabState.webApps.openedWebApps; const openedWebApps = currentTabState.webApps.openedWebApps;
const webApp = openedWebApps[key];
if (!webApp) return global;
if (!skipClosingConfirmation && webApp.shouldConfirmClosing) { if (!skipClosingConfirmation && webApp.shouldConfirmClosing) {
return updateWebApp(global, { ...webApp, isCloseModalOpen: true }, tabId); return updateWebApp(global, key, { isCloseModalOpen: true }, tabId);
} }
const updatedOpenedWebApps = { ...openedWebApps }; const updatedOpenedWebApps = { ...openedWebApps };
@ -164,7 +167,7 @@ export function removeWebAppFromOpenList<T extends GlobalState>(
if (removingWebAppKey) { if (removingWebAppKey) {
delete updatedOpenedWebApps[removingWebAppKey]; delete updatedOpenedWebApps[removingWebAppKey];
newOpenedKeys = currentTabState.webApps.openedOrderedKeys.filter((key) => key !== removingWebAppKey); newOpenedKeys = currentTabState.webApps.openedOrderedKeys.filter((k) => k !== removingWebAppKey);
} }
const activeWebApp = currentTabState.webApps.activeWebApp; const activeWebApp = currentTabState.webApps.activeWebApp;

View File

@ -150,3 +150,9 @@ export function selectIsSynced<T extends GlobalState>(global: T) {
export function selectCanAnimateSnapEffect<T extends GlobalState>(global: T) { export function selectCanAnimateSnapEffect<T extends GlobalState>(global: T) {
return IS_SNAP_EFFECT_SUPPORTED && selectPerformanceSettingsValue(global, 'snapEffect'); return IS_SNAP_EFFECT_SUPPORTED && selectPerformanceSettingsValue(global, 'snapEffect');
} }
export function selectWebApp<T extends GlobalState>(
global: T, key: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
return selectTabState(global, tabId).webApps.openedWebApps[key];
}

View File

@ -911,6 +911,13 @@ export type TabState = {
userId?: string; userId?: string;
gift: ApiUserStarGift | ApiStarGift; gift: ApiUserStarGift | ApiStarGift;
}; };
suggestedStatusModal?: {
botId: string;
webAppKey?: string;
customEmojiId: string;
duration?: number;
};
}; };
export type GlobalState = { export type GlobalState = {
@ -1333,6 +1340,7 @@ export type WebApp = {
backgroundColor?: string; backgroundColor?: string;
isBackButtonVisible?: boolean; isBackButtonVisible?: boolean;
isSettingsButtonVisible?: boolean; isSettingsButtonVisible?: boolean;
plannedEvents?: WebAppOutboundEvent[];
sendEvent?: (event: WebAppOutboundEvent) => void; sendEvent?: (event: WebAppOutboundEvent) => void;
reloadFrame?: (url: string) => void; reloadFrame?: (url: string) => void;
}; };
@ -3115,7 +3123,8 @@ export interface ActionPayloads {
startParam?: string; startParam?: string;
} & WithTabId; } & WithTabId;
updateWebApp: { updateWebApp: {
webApp: Partial<WebApp>; key: string;
update: Partial<WebApp>;
} & WithTabId; } & WithTabId;
requestMainWebView: { requestMainWebView: {
botId: string; botId: string;
@ -3260,9 +3269,13 @@ export interface ActionPayloads {
openMoreAppsTab: WithTabId | undefined; openMoreAppsTab: WithTabId | undefined;
closeMoreAppsTab: WithTabId | undefined; closeMoreAppsTab: WithTabId | undefined;
closeWebApp: { closeWebApp: {
webApp: WebApp; key: string;
skipClosingConfirmation?: boolean; skipClosingConfirmation?: boolean;
} & WithTabId; } & WithTabId;
sendWebAppEvent: {
webAppKey: string;
event: WebAppOutboundEvent;
} & WithTabId;
closeWebAppModal: ({ closeWebAppModal: ({
shouldSkipConfirmation?: boolean; shouldSkipConfirmation?: boolean;
} & WithTabId) | undefined; } & WithTabId) | undefined;
@ -3546,9 +3559,17 @@ export interface ActionPayloads {
closeStarsGiftModal: WithTabId | undefined; closeStarsGiftModal: WithTabId | undefined;
setEmojiStatus: { setEmojiStatus: {
emojiStatus: ApiSticker; emojiStatusId: string;
expires?: number; expires?: number;
}; referrerWebAppKey?: string;
} & WithTabId;
openSuggestedStatusModal: {
botId: string;
webAppKey?: string;
customEmojiId: string;
duration?: number;
} & WithTabId;
closeSuggestedStatusModal: WithTabId | undefined;
// Invoice // Invoice
openInvoice: Exclude<ApiInputInvoice, ApiInputInvoiceStarGift> & WithTabId; openInvoice: Exclude<ApiInputInvoice, ApiInputInvoiceStarGift> & WithTabId;

View File

@ -509,6 +509,7 @@ export type CustomPeer = {
peerColorId?: number; peerColorId?: number;
isVerified?: boolean; isVerified?: boolean;
fakeType?: ApiFakeType; fakeType?: ApiFakeType;
emojiStatusId?: string;
customPeerAvatarColor?: string; customPeerAvatarColor?: string;
withPremiumGradient?: boolean; withPremiumGradient?: boolean;
} & ({ } & ({

View File

@ -943,6 +943,7 @@ export interface LangPair {
'ScheduleSendWhenOnline': undefined; 'ScheduleSendWhenOnline': undefined;
'VoipIncoming': undefined; 'VoipIncoming': undefined;
'LiveLocationUpdatedJustNow': undefined; 'LiveLocationUpdatedJustNow': undefined;
'RightNow': undefined;
'AudioPause': undefined; 'AudioPause': undefined;
'AudioPlay': undefined; 'AudioPlay': undefined;
'ToggleUserNotifications': undefined; 'ToggleUserNotifications': undefined;
@ -1152,6 +1153,8 @@ export interface LangPair {
'CloseMiniApps': undefined; 'CloseMiniApps': undefined;
'DoNotAskAgain': undefined; 'DoNotAskAgain': undefined;
'PaymentInfoDone': undefined; 'PaymentInfoDone': undefined;
'BotSuggestedStatusTitle': undefined;
'BotSuggestedStatusUpdated': undefined;
} }
export interface LangPairWithVariables<V extends unknown = LangVariable> { export interface LangPairWithVariables<V extends unknown = LangVariable> {
@ -1541,6 +1544,13 @@ export interface LangPairWithVariables<V extends unknown = LangVariable> {
'StarsPerMonth': { 'StarsPerMonth': {
'amount': V; 'amount': V;
}; };
'BotSuggestedStatusFor': {
'bot': V;
'duration': V;
};
'BotSuggestedStatus': {
'bot': V;
};
} }
export interface LangPairPlural { export interface LangPairPlural {

View File

@ -95,6 +95,10 @@ export type WebAppInboundEvent =
WebAppEvent<'web_app_biometry_update_token', { WebAppEvent<'web_app_biometry_update_token', {
token: string; token: string;
}> | }> |
WebAppEvent<'web_app_set_emoji_status', {
custom_emoji_id: string;
duration?: number;
}> |
WebAppEvent<'web_app_request_viewport' | 'web_app_request_theme' | 'web_app_ready' | 'web_app_expand' WebAppEvent<'web_app_request_viewport' | 'web_app_request_theme' | 'web_app_ready' | 'web_app_expand'
| 'web_app_request_phone' | 'web_app_close' | 'web_app_close_scan_qr_popup' | 'web_app_request_phone' | 'web_app_close' | 'web_app_close_scan_qr_popup'
| 'web_app_request_write_access' | 'web_app_request_phone' | 'iframe_will_reload' | 'web_app_request_write_access' | 'web_app_request_phone' | 'iframe_will_reload'
@ -168,6 +172,10 @@ export type WebAppOutboundEvent =
WebAppEvent<'biometry_token_updated', { WebAppEvent<'biometry_token_updated', {
status: 'updated' | 'removed' | 'failed'; status: 'updated' | 'removed' | 'failed';
}> | }> |
WebAppEvent<'emoji_status_failed', {
error: 'UNSUPPORTED' | 'USER_DECLINED' | 'SUGGESTED_EMOJI_INVALID'
| 'DURATION_INVALID' | 'SERVER_ERROR' | 'UNKNOWN_ERROR';
}> |
WebAppEvent<'main_button_pressed' | WebAppEvent<'main_button_pressed' |
'secondary_button_pressed' | 'back_button_pressed' | 'settings_button_pressed' | 'scan_qr_popup_closed' 'secondary_button_pressed' | 'back_button_pressed' | 'settings_button_pressed' | 'scan_qr_popup_closed'
| 'reload_iframe', null>; | 'reload_iframe' | 'emoji_status_set', null>;

View File

@ -1,5 +1,6 @@
import type { OldLangFn } from '../../hooks/useOldLang'; import type { OldLangFn } from '../../hooks/useOldLang';
import type { TimeFormat } from '../../types'; import type { TimeFormat } from '../../types';
import type { LangFn } from '../localization';
import withCache from '../withCache'; import withCache from '../withCache';
@ -374,29 +375,28 @@ export function formatDateAtTime(
return lang('formatDateAtTime', [formattedDate, time]); return lang('formatDateAtTime', [formattedDate, time]);
} }
export function formatDateInFuture( export function formatShortDuration(lang: LangFn, duration: number) {
lang: OldLangFn, if (duration < 0) {
currentTime: number,
datetime: number,
) {
const diff = Math.ceil(datetime - currentTime);
if (diff < 0) {
return lang('RightNow'); return lang('RightNow');
} }
if (diff < 60) { if (duration < 60) {
return lang('Seconds', diff); const count = Math.ceil(duration);
return lang('Seconds', { count }, { pluralValue: duration });
} }
if (diff < 60 * 60) { if (duration < 60 * 60) {
return lang('Minutes', Math.ceil(diff / 60)); const count = Math.ceil(duration / 60);
return lang('Minutes', { count }, { pluralValue: count });
} }
if (diff < 60 * 60 * 24) { if (duration < 60 * 60 * 24) {
return lang('Hours', Math.ceil(diff / (60 * 60))); const count = Math.ceil(duration / (60 * 60));
return lang('Hours', { count }, { pluralValue: count });
} }
return lang('Days', Math.ceil(diff / (60 * 60 * 24))); const count = Math.ceil(duration / (60 * 60 * 24));
return lang('Days', { count }, { pluralValue: count });
} }
function isValidDate(day: number, month: number, year = 2021): boolean { function isValidDate(day: number, month: number, year = 2021): boolean {