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();
}
export function buildInputEmojiStatus(emojiStatus: ApiSticker, expires?: number) {
if (emojiStatus.id === DEFAULT_STATUS_ICON_ID) {
export function buildInputEmojiStatus(emojiStatusId: string, expires?: number) {
if (emojiStatusId === DEFAULT_STATUS_ICON_ID) {
return new GramJs.EmojiStatusEmpty();
}
if (expires) {
return new GramJs.EmojiStatusUntil({
documentId: BigInt(emojiStatus.id),
documentId: BigInt(emojiStatusId),
until: expires,
});
}
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 type {
ApiChat, ApiPeer, ApiSticker, ApiUser,
ApiChat, ApiPeer, ApiUser,
} from '../../types';
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({
emojiStatus: buildInputEmojiStatus(emojiStatus, expires),
emojiStatus: buildInputEmojiStatus(emojiStatusId, expires),
}), {
shouldReturnTrue: true,
});

View File

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

View File

@ -1111,6 +1111,7 @@
"LiveLocationUpdatedMinutesAgo_one" = "updated 1 minute ago";
"LiveLocationUpdatedMinutesAgo_other" = "updated {count} minutes ago";
"LiveLocationUpdatedTodayAt" = "updated at {time}";
"RightNow" = "Just now";
"Seconds_one" = "{count} second";
"Seconds_other" = "{count} seconds";
"Minutes_one" = "{count} minute";
@ -1383,3 +1384,7 @@
"CloseMiniApps" = "Close Mini Apps";
"DoNotAskAgain" = "Don't ask again";
"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 PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
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 GiftCodeModal } from '../components/modals/giftcode/GiftCodeModal';
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 buildClassName from '../../util/buildClassName';
import { getServerTimeOffset } from '../../util/serverTime';
import { getServerTime } from '../../util/serverTime';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
@ -198,8 +198,8 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
handleContextMenuClose();
onContextMenuClick?.();
setEmojiStatus({
emojiStatus: sticker,
expires: Date.now() / 1000 + duration + getServerTimeOffset(),
emojiStatusId: sticker.id,
expires: getServerTime() + duration,
});
});

View File

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

View File

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

View File

@ -138,6 +138,7 @@
&.has-inline-buttons {
.message-content {
--border-bottom-left-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 StarsSubscriptionModal from './stars/subscription/StarsSubscriptionModal.async';
import StarsTransactionInfoModal from './stars/transaction/StarsTransactionModal.async';
import SuggestedStatusModal from './suggestedStatus/SuggestedStatusModal.async';
import UrlAuthModal from './urlAuth/UrlAuthModal.async';
import WebAppModal from './webApp/WebAppModal.async';
@ -57,6 +58,7 @@ type ModalKey = keyof Pick<TabState,
'isGiftRecipientPickerOpen' |
'isWebAppsCloseConfirmationModalOpen' |
'giftInfoModal' |
'suggestedStatusModal' |
'aboutAdsModal'
>;
@ -96,6 +98,7 @@ const MODALS: ModalRegistry = {
isGiftRecipientPickerOpen: GiftRecipientPicker,
isWebAppsCloseConfirmationModalOpen: WebAppsCloseConfirmationModal,
giftInfoModal: GiftInfoModal,
suggestedStatusModal: SuggestedStatusModal,
aboutAdsModal: AboutAdsModal,
};
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 { selectChat, selectChatFullInfo, selectIsCurrentUserPremium } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { formatDateInFuture } from '../../../util/dates/dateFormat';
import { formatShortDuration } from '../../../util/dates/dateFormat';
import { getServerTime } from '../../../util/serverTime';
import { getBoostProgressInfo } from '../../common/helpers/boostInfo';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
@ -79,7 +80,8 @@ const BoostModal = ({
const isOpen = Boolean(modal);
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
useEffect(() => {
if (chat && !chatFullInfo) {
@ -92,16 +94,16 @@ const BoostModal = ({
return undefined;
}
return getChatTitle(lang, chat);
}, [chat, lang]);
return getChatTitle(oldLang, chat);
}, [chat, oldLang]);
const boostedChatTitle = useMemo(() => {
if (!prevBoostedChat) {
return undefined;
}
return getChatTitle(lang, prevBoostedChat);
}, [prevBoostedChat, lang]);
return getChatTitle(oldLang, prevBoostedChat);
}, [prevBoostedChat, oldLang]);
const {
isStatusLoaded,
@ -118,7 +120,7 @@ const BoostModal = ({
if (!modal?.boostStatus || !chat) {
return {
isStatusLoaded: false,
title: lang('Loading'),
title: oldLang('Loading'),
};
}
@ -140,23 +142,23 @@ const BoostModal = ({
const hasBoost = hasMyBoost;
const left = lang('BoostsLevel', currentLevel);
const right = hasNextLevel ? lang('BoostsLevel', currentLevel + 1) : undefined;
const left = oldLang('BoostsLevel', currentLevel);
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);
let description: string | undefined;
if (isMaxLevel) {
description = lang('BoostsMaxLevelReached');
description = oldLang('BoostsMaxLevelReached');
} else if (boostsLeftToUnrestrict > 0 && !isChatAdmin(chat)) {
const boostTimes = lang('GroupBoost.BoostToUnrestrict.Times', boostsLeftToUnrestrict);
description = lang('GroupBoost.BoostToUnrestrict', [boostTimes, chatTitle]);
const boostTimes = oldLang('GroupBoost.BoostToUnrestrict.Times', boostsLeftToUnrestrict);
description = oldLang('GroupBoost.BoostToUnrestrict', [boostTimes, chatTitle]);
} else {
description = lang('ChannelBoost.MoreBoostsNeeded.Text', [chatTitle, moreBoosts]);
description = oldLang('ChannelBoost.MoreBoostsNeeded.Text', [chatTitle, moreBoosts]);
}
return {
@ -172,7 +174,7 @@ const BoostModal = ({
isBoosted: hasBoost,
canBoostMore: areBoostsInDifferentChannels && !isMaxLevel,
};
}, [chat, chatTitle, modal, lang, chatFullInfo, isChannel]);
}, [chat, chatTitle, modal, oldLang, chatFullInfo, isChannel]);
const isBoostDisabled = !modal?.myBoosts?.length && isCurrentUserPremium;
const isReplacingBoost = boost?.chatId && boost.chatId !== modal?.chatId;
@ -238,7 +240,7 @@ const BoostModal = ({
/>
{isBoosted && (
<div className={buildClassName(styles.description, styles.bold)}>
{lang('ChannelBoost.YouBoostedChannelText', chatTitle)}
{oldLang('ChannelBoost.YouBoostedChannelText', chatTitle)}
</div>
)}
<div className={styles.description}>
@ -249,12 +251,12 @@ const BoostModal = ({
{canBoostMore ? (
<>
<Icon name="boost" />
{lang(isChannel ? 'ChannelBoost.BoostChannel' : 'GroupBoost.BoostGroup')}
{oldLang(isChannel ? 'ChannelBoost.BoostChannel' : 'GroupBoost.BoostGroup')}
</>
) : lang('OK')}
) : oldLang('OK')}
</Button>
<Button isText className="confirm-dialog-button" onClick={handleCloseClick}>
{lang('Cancel')}
{oldLang('Cancel')}
</Button>
</div>
</>
@ -286,14 +288,16 @@ const BoostModal = ({
<Avatar peer={chat} size="large" />
</div>
<div>
{renderText(lang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'])}
{renderText(
oldLang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'],
)}
</div>
<div className="dialog-buttons">
<Button isText className="confirm-dialog-button" onClick={handleApplyBoost}>
{lang('Replace')}
{oldLang('Replace')}
</Button>
<Button isText className="confirm-dialog-button" onClick={closeReplaceModal}>
{lang('Cancel')}
{oldLang('Cancel')}
</Button>
</div>
</Modal>
@ -302,15 +306,15 @@ const BoostModal = ({
<ConfirmDialog
isOpen={isWaitDialogOpen}
isOnlyConfirm
confirmLabel={lang('OK')}
title={lang('ChannelBoost.Error.BoostTooOftenTitle')}
confirmLabel={oldLang('OK')}
title={oldLang('ChannelBoost.Error.BoostTooOftenTitle')}
onClose={closeWaitDialog}
confirmHandler={closeWaitDialog}
>
{renderText(
lang(
oldLang(
'ChannelBoost.Error.BoostTooOftenText',
formatDateInFuture(lang, getServerTime(), boost!.cooldownUntil),
formatShortDuration(lang, boost!.cooldownUntil - getServerTime()),
),
['simple_markdown', 'emoji'],
)}
@ -319,12 +323,12 @@ const BoostModal = ({
{!isCurrentUserPremium && (
<ConfirmDialog
isOpen={isPremiumDialogOpen}
confirmLabel={lang('Common.Yes')}
title={lang('PremiumNeeded')}
confirmLabel={oldLang('Common.Yes')}
title={oldLang('PremiumNeeded')}
onClose={closePremiumDialog}
confirmHandler={handleProceedPremium}
>
{renderText(lang('PremiumNeededForBoosting'), ['simple_markdown', 'emoji'])}
{renderText(oldLang('PremiumNeededForBoosting'), ['simple_markdown', 'emoji'])}
</ConfirmDialog>
)}
</Modal>

View File

@ -10,6 +10,7 @@ import buildClassName from '../../../../util/buildClassName';
import { formatDateTimeToString } from '../../../../util/dates/dateFormat';
import { formatStarsAsIcon, formatStarsAsText } from '../../../../util/localization/format';
import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer';
import { getServerTime } from '../../../../util/serverTime';
import { formatInteger } from '../../../../util/textFormat';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
@ -67,7 +68,7 @@ const GiftInfoModal = ({
const canUpdate = Boolean(userGift?.fromId && userGift.messageId);
const isSender = userGift?.fromId === currentUserId;
const canConvertDifference = (userGift && starGiftMaxConvertPeriod && (
userGift.date + starGiftMaxConvertPeriod - Date.now() / 1000
userGift.date + starGiftMaxConvertPeriod - getServerTime()
)) || 0;
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(() => {
if (attachBot) {
const key = getWebAppKey(activeWebApp!);
updateWebApp({
webApp: {
...activeWebApp!,
key,
update: {
isRemoveModalOpen: true,
},
});

View File

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

View File

@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { WebApp } from '../../../../global/types';
import type { WebAppInboundEvent, WebAppOutboundEvent } from '../../../../types/webapp';
import { getWebAppKey } from '../../../../global/helpers';
import { extractCurrentThemeParams } from '../../../../util/themeStyle';
import useLastCallback from '../../../../hooks/useLastCallback';
@ -44,6 +46,8 @@ const useWebAppFrame = (
setWebAppPaymentSlug,
openInvoice,
closeWebApp,
openSuggestedStatusModal,
updateWebApp,
} = getActions();
const isReloadSupported = useRef<boolean>(false);
@ -146,7 +150,10 @@ const useWebAppFrame = (
}
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') {
@ -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);
} catch (err) {
// Ignore other messages
@ -231,6 +283,21 @@ const useWebAppFrame = (
sendViewport(isResizing);
}, [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(() => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import type {
ActionReturnType, GlobalState, TabArgs, WebApp,
} from '../../types';
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,
} from '../../../api/types';
import { ManagementProgress } from '../../../types';
@ -30,10 +30,9 @@ import {
} from '../../reducers';
import {
activateWebAppIfOpen,
addWebAppToOpenList, clearOpenedWebApps, hasOpenedMoreThanOneWebApps,
hasOpenedWebApps, removeActiveWebAppFromOpenList, removeWebAppFromOpenList,
replaceInlineBotSettings, replaceInlineBotsIsLoading,
replaceIsWebAppModalOpen, replaceWebAppModalState, updateWebApp,
addWebAppToOpenList,
replaceInlineBotSettings,
replaceInlineBotsIsLoading,
} from '../../reducers/bots';
import { updateTabState } from '../../reducers/tabs';
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 => {
const {
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> => {
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> => {
const {
chatId, buttonId, messageId, url, tabId = getCurrentTabId(),

View File

@ -29,9 +29,11 @@ import {
updateUserSearch,
updateUserSearchFetchingStatus,
} from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import {
selectChat,
selectChatFullInfo,
selectIsCurrentUserPremium,
selectPeer,
selectPeerPhotos,
selectTabState,
@ -388,10 +390,62 @@ addActionHandler('reportSpam', (global, actions, payload): ActionReturnType => {
void callApi('reportSpam', peer);
});
addActionHandler('setEmojiStatus', (global, actions, payload): ActionReturnType => {
const { emojiStatus, expires } = payload;
addActionHandler('setEmojiStatus', async (global, actions, payload): Promise<void> => {
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> => {
@ -418,3 +472,39 @@ addActionHandler('saveCloseFriends', async (global, actions, payload): Promise<v
});
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 { getCurrentTabId } from '../../../util/establishMultitabRole';
import { getWebAppKey } from '../../helpers';
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 => {
const {
@ -15,3 +29,199 @@ addActionHandler('openWebAppTab', (global, actions, payload): ActionReturnType =
global = addWebAppToOpenList(global, webApp, true, true, tabId);
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 {
query,
tabId = getCurrentTabId(),
} = payload!;
} = payload;
return updateUserSearch(global, {
globalUserIds: undefined,
@ -20,7 +20,7 @@ addActionHandler('setUserSearchQuery', (global, actions, payload): ActionReturnT
});
addActionHandler('openAddContactDialog', (global, actions, payload): ActionReturnType => {
const { userId, tabId = getCurrentTabId() } = payload!;
const { userId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
newContact: { userId },
@ -42,3 +42,11 @@ addActionHandler('closeNewContactDialog', (global, actions, payload): ActionRetu
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>) {
if (webApp.requestUrl) return webApp.requestUrl;
if (webApp.appName) return `${webApp.botId}?appName=${webApp.appName}`;
return webApp.botId;
return webApp.botId!;
}
export function isSystemBot(botId: string) {

View File

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

View File

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

View File

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

View File

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

View File

@ -95,6 +95,10 @@ export type WebAppInboundEvent =
WebAppEvent<'web_app_biometry_update_token', {
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'
| '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'
@ -168,6 +172,10 @@ export type WebAppOutboundEvent =
WebAppEvent<'biometry_token_updated', {
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' |
'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 { TimeFormat } from '../../types';
import type { LangFn } from '../localization';
import withCache from '../withCache';
@ -374,29 +375,28 @@ export function formatDateAtTime(
return lang('formatDateAtTime', [formattedDate, time]);
}
export function formatDateInFuture(
lang: OldLangFn,
currentTime: number,
datetime: number,
) {
const diff = Math.ceil(datetime - currentTime);
if (diff < 0) {
export function formatShortDuration(lang: LangFn, duration: number) {
if (duration < 0) {
return lang('RightNow');
}
if (diff < 60) {
return lang('Seconds', diff);
if (duration < 60) {
const count = Math.ceil(duration);
return lang('Seconds', { count }, { pluralValue: duration });
}
if (diff < 60 * 60) {
return lang('Minutes', Math.ceil(diff / 60));
if (duration < 60 * 60) {
const count = Math.ceil(duration / 60);
return lang('Minutes', { count }, { pluralValue: count });
}
if (diff < 60 * 60 * 24) {
return lang('Hours', Math.ceil(diff / (60 * 60)));
if (duration < 60 * 60 * 24) {
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 {