diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index fa86e803f..32cd95d51 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -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), }); } diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 3be68551f..57716fa02 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -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, }); diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 76b319e3b..e3be96104 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -125,6 +125,7 @@ export type ApiNotification = { disableClickDismiss?: boolean; shouldShowTimer?: boolean; icon?: IconName; + customEmojiIconId?: string; dismissAction?: CallbackAction; }; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 29ce536f1..e91136270 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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."; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index fdd3caaea..debfcd513 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index 22ba6b55b..309ba718d 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -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 = = { // 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 = { 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 = ({ isMinimized, canClose, clickArg, - chat, - user, + peer, + mockPeer, customPeer, className, fluid, @@ -60,6 +60,11 @@ const PickerSelectedItem = ({ }: OwnProps & 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 = ({ ); titleText = title; - } else if (customPeer || user || chat) { + } else if (anyPeer) { iconElement = ( ); - 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 || ; } const fullClassName = buildClassName( @@ -95,13 +95,13 @@ const PickerSelectedItem = ({ isMinimized && 'minimized', canClose && 'closeable', fluid && 'fluid', - withPeerColors && getPeerColorClass(customPeer || chat || user), + withPeerColors && getPeerColorClass(customPeer || peer), ); return (
onClick(clickArg)} + onClick={() => onClick?.(clickArg!)} title={isMinimized ? titleText : undefined} dir={lang.isRtl ? 'rtl' : undefined} > @@ -126,13 +126,12 @@ export default memo(withGlobal( 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, }; }, diff --git a/src/components/left/main/StatusButton.tsx b/src/components/left/main/StatusButton.tsx index f072c8d63..062255239 100644 --- a/src/components/left/main/StatusButton.tsx +++ b/src/components/left/main/StatusButton.tsx @@ -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 = ({ 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 = ({ emojiStatus }) => { const handleEmojiStatusSet = useCallback((sticker: ApiSticker) => { markShouldShowEffect(); - setEmojiStatus({ emojiStatus: sticker }); + setEmojiStatus({ emojiStatusId: sticker.id }); }, [markShouldShowEffect, setEmojiStatus]); useTimeout(hideEffect, isEffectShown ? EFFECT_DURATION_MS : undefined); diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 1af7d7f1b..3c1e65f04 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -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); } } diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index fd3cc0ca6..03204c493 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -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; @@ -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[]; diff --git a/src/components/modals/boost/BoostModal.tsx b/src/components/modals/boost/BoostModal.tsx index dc82dc326..4f300df6e 100644 --- a/src/components/modals/boost/BoostModal.tsx +++ b/src/components/modals/boost/BoostModal.tsx @@ -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 && (
- {lang('ChannelBoost.YouBoostedChannelText', chatTitle)} + {oldLang('ChannelBoost.YouBoostedChannelText', chatTitle)}
)}
@@ -249,12 +251,12 @@ const BoostModal = ({ {canBoostMore ? ( <> - {lang(isChannel ? 'ChannelBoost.BoostChannel' : 'GroupBoost.BoostGroup')} + {oldLang(isChannel ? 'ChannelBoost.BoostChannel' : 'GroupBoost.BoostGroup')} - ) : lang('OK')} + ) : oldLang('OK')}
@@ -286,14 +288,16 @@ const BoostModal = ({
- {renderText(lang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'])} + {renderText( + oldLang('ChannelBoost.ReplaceBoost', [boostedChatTitle, chatTitle]), ['simple_markdown', 'emoji'], + )}
@@ -302,15 +306,15 @@ const BoostModal = ({ {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 && ( - {renderText(lang('PremiumNeededForBoosting'), ['simple_markdown', 'emoji'])} + {renderText(oldLang('PremiumNeededForBoosting'), ['simple_markdown', 'emoji'])} )} diff --git a/src/components/modals/gift/info/GiftInfoModal.tsx b/src/components/modals/gift/info/GiftInfoModal.tsx index 4749ddef0..b949809c2 100644 --- a/src/components/modals/gift/info/GiftInfoModal.tsx +++ b/src/components/modals/gift/info/GiftInfoModal.tsx @@ -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); diff --git a/src/components/modals/suggestedStatus/SuggestedStatusModal.async.tsx b/src/components/modals/suggestedStatus/SuggestedStatusModal.async.tsx new file mode 100644 index 000000000..aa5a95a2b --- /dev/null +++ b/src/components/modals/suggestedStatus/SuggestedStatusModal.async.tsx @@ -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 = (props) => { + const { modal } = props; + const SuggestedStatusModal = useModuleLoader(Bundles.Extra, 'SuggestedStatusModal', !modal); + + // eslint-disable-next-line react/jsx-props-no-spreading + return SuggestedStatusModal ? : undefined; +}; + +export default SuggestedStatusModalAsync; diff --git a/src/components/modals/suggestedStatus/SuggestedStatusModal.module.scss b/src/components/modals/suggestedStatus/SuggestedStatusModal.module.scss new file mode 100644 index 000000000..3da556918 --- /dev/null +++ b/src/components/modals/suggestedStatus/SuggestedStatusModal.module.scss @@ -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; +} diff --git a/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx b/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx new file mode 100644 index 000000000..c342ad2db --- /dev/null +++ b/src/components/modals/suggestedStatus/SuggestedStatusModal.tsx @@ -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 ( + + {renderingModal && ( + + )} +
+

{lang('BotSuggestedStatusTitle')}

+

{description}

+
+ {mockPeerWithStatus && ( + + )} + +
+ ); +}; + +export default memo(withGlobal( + (global, { modal }): StateProps => { + const currentUser = selectUser(global, global.currentUserId!); + const bot = modal?.botId ? selectUser(global, modal.botId) : undefined; + + return { + currentUser, + bot, + }; + }, +)(SuggestedStatusModal)); diff --git a/src/components/modals/webApp/WebAppModal.tsx b/src/components/modals/webApp/WebAppModal.tsx index 5a12fbce2..b7b3f5b29 100644 --- a/src/components/modals/webApp/WebAppModal.tsx +++ b/src/components/modals/webApp/WebAppModal.tsx @@ -248,9 +248,10 @@ const WebAppModal: FC = ({ const handleToggleClick = useLastCallback(() => { if (attachBot) { + const key = getWebAppKey(activeWebApp!); updateWebApp({ - webApp: { - ...activeWebApp!, + key, + update: { isRemoveModalOpen: true, }, }); diff --git a/src/components/modals/webApp/WebAppModalTabContent.tsx b/src/components/modals/webApp/WebAppModalTabContent.tsx index 76363b594..467095587 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.tsx +++ b/src/components/modals/webApp/WebAppModalTabContent.tsx @@ -140,13 +140,8 @@ const WebAppModalTabContent: FC = ({ const isActive = (activeWebApp && webApp) && activeWebAppKey === webAppKey; const updateCurrentWebApp = useLastCallback((updatedPartialWebApp: Partial) => { - if (!webApp) return; - const updatedWebApp = { - ...webApp, - ...updatedPartialWebApp, - }; - webApp = updatedWebApp; - updateWebApp({ webApp: updatedWebApp }); + if (!webAppKey) return; + updateWebApp({ key: webAppKey, update: updatedPartialWebApp }); }); useEffect(() => { diff --git a/src/components/modals/webApp/hooks/useWebAppFrame.ts b/src/components/modals/webApp/hooks/useWebAppFrame.ts index a2be962da..755145f27 100644 --- a/src/components/modals/webApp/hooks/useWebAppFrame.ts +++ b/src/components/modals/webApp/hooks/useWebAppFrame.ts @@ -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(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); diff --git a/src/components/ui/Modal.scss b/src/components/ui/Modal.scss index 0716e4925..e9975c66e 100644 --- a/src/components/ui/Modal.scss +++ b/src/components/ui/Modal.scss @@ -165,7 +165,6 @@ .modal-content, .modal-content > p { unicode-bidi: plaintext; - text-align: initial; } .modal-about { diff --git a/src/components/ui/Notification.scss b/src/components/ui/Notification.scss index 403ef5354..04fceed31 100644 --- a/src/components/ui/Notification.scss +++ b/src/components/ui/Notification.scss @@ -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; } diff --git a/src/components/ui/Notification.tsx b/src/components/ui/Notification.tsx index e9deeded1..060b3efb8 100644 --- a/src/components/ui/Notification.tsx +++ b/src/components/ui/Notification.tsx @@ -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 = ({ notification, @@ -51,6 +54,7 @@ const Notification: FC = ({ dismissAction, duration = DEFAULT_DURATION, icon, + customEmojiIconId, shouldShowTimer, title, containerSelector, @@ -155,7 +159,16 @@ const Notification: FC = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - + {customEmojiIconId ? ( + + ) : ( + + )}
{renderedTitle && (
{renderedTitle}
diff --git a/src/global/actions/all.ts b/src/global/actions/all.ts index 5169bf830..0146942f3 100644 --- a/src/global/actions/all.ts +++ b/src/global/actions/all.ts @@ -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'; diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index 763d03ede..1902487dc 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -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 => { 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 => { const { chatId, buttonId, messageId, url, tabId = getCurrentTabId(), diff --git a/src/global/actions/api/users.ts b/src/global/actions/api/users.ts index dde03133c..f686a344a 100644 --- a/src/global/actions/api/users.ts +++ b/src/global/actions/api/users.ts @@ -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 => { + 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 => { @@ -418,3 +472,39 @@ addActionHandler('saveCloseFriends', async (global, actions, payload): Promise => { + 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); +}); diff --git a/src/global/actions/ui/bots.ts b/src/global/actions/ui/bots.ts index f85f89eab..d5a76837a 100644 --- a/src/global/actions/ui/bots.ts +++ b/src/global/actions/ui/bots.ts @@ -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); +}); diff --git a/src/global/actions/ui/users.ts b/src/global/actions/ui/users.ts index 352a85beb..d928a7652 100644 --- a/src/global/actions/ui/users.ts +++ b/src/global/actions/ui/users.ts @@ -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); +}); diff --git a/src/global/helpers/bots.ts b/src/global/helpers/bots.ts index 3cb16b7c8..2140ba834 100644 --- a/src/global/helpers/bots.ts +++ b/src/global/helpers/bots.ts @@ -20,7 +20,7 @@ export function convertToApiChatType(type: string): ApiChatType | undefined { export function getWebAppKey(webApp: Partial) { 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) { diff --git a/src/global/reducers/bots.ts b/src/global/reducers/bots.ts index f310b1804..d953a6268 100644 --- a/src/global/reducers/bots.ts +++ b/src/global/reducers/bots.ts @@ -37,20 +37,19 @@ export function replaceInlineBotsIsLoading( } export function updateWebApp ( - global: T, webApp: Partial, + global: T, key: string, webAppUpdate: Partial, ...[tabId = getCurrentTabId()]: TabArgs ): 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( 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( - global: T, webApp: WebApp, skipClosingConfirmation?: boolean, + global: T, key: string, skipClosingConfirmation?: boolean, ...[tabId = getCurrentTabId()]: TabArgs ): 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( 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; diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 1b407ba56..38ebb543a 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -150,3 +150,9 @@ export function selectIsSynced(global: T) { export function selectCanAnimateSnapEffect(global: T) { return IS_SNAP_EFFECT_SUPPORTED && selectPerformanceSettingsValue(global, 'snapEffect'); } + +export function selectWebApp( + global: T, key: string, ...[tabId = getCurrentTabId()]: TabArgs +) { + return selectTabState(global, tabId).webApps.openedWebApps[key]; +} diff --git a/src/global/types.ts b/src/global/types.ts index bf9b01de5..b2edd8fa9 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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; + key: string; + update: Partial; } & 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 & WithTabId; diff --git a/src/types/index.ts b/src/types/index.ts index 6f8497d7d..27515e9ed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -509,6 +509,7 @@ export type CustomPeer = { peerColorId?: number; isVerified?: boolean; fakeType?: ApiFakeType; + emojiStatusId?: string; customPeerAvatarColor?: string; withPremiumGradient?: boolean; } & ({ diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 769eb2618..005d3b56a 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -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 { @@ -1541,6 +1544,13 @@ export interface LangPairWithVariables { 'StarsPerMonth': { 'amount': V; }; + 'BotSuggestedStatusFor': { + 'bot': V; + 'duration': V; + }; + 'BotSuggestedStatus': { + 'bot': V; + }; } export interface LangPairPlural { diff --git a/src/types/webapp.ts b/src/types/webapp.ts index 9d2bb0cb8..917cce368 100644 --- a/src/types/webapp.ts +++ b/src/types/webapp.ts @@ -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>; diff --git a/src/util/dates/dateFormat.ts b/src/util/dates/dateFormat.ts index 9aa981808..eb954694a 100644 --- a/src/util/dates/dateFormat.ts +++ b/src/util/dates/dateFormat.ts @@ -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 {