diff --git a/src/api/gramjs/apiBuilders/bots.ts b/src/api/gramjs/apiBuilders/bots.ts index d86141fec..512539b8a 100644 --- a/src/api/gramjs/apiBuilders/bots.ts +++ b/src/api/gramjs/apiBuilders/bots.ts @@ -1,12 +1,24 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { - ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm, ApiInlineResultType, ApiWebDocument, + ApiAttachMenuBot, + ApiAttachMenuBotIcon, + ApiBotCommand, + ApiBotInfo, + ApiBotInlineMediaResult, + ApiBotInlineResult, + ApiBotInlineSwitchPm, + ApiBotMenuButton, + ApiInlineResultType, + ApiWebDocument, } from '../../types'; import { pick } from '../../../util/iteratees'; import { buildApiPhoto, buildApiThumbnailFromStripped } from './common'; -import { buildVideoFromDocument } from './messages'; +import { buildApiDocument, buildVideoFromDocument } from './messages'; import { buildStickerFromDocument } from './symbols'; +import localDb from '../localDb'; +import { buildApiPeerId } from './peers'; +import { omitVirtualClassFields } from './helpers'; export function buildApiBotInlineResult(result: GramJs.BotInlineResult, queryId: string): ApiBotInlineResult { const { @@ -50,6 +62,66 @@ export function buildBotSwitchPm(switchPm?: GramJs.InlineBotSwitchPM) { return switchPm ? pick(switchPm, ['text', 'startParam']) as ApiBotInlineSwitchPm : undefined; } +export function buildApiAttachMenuBot(bot: GramJs.AttachMenuBot): ApiAttachMenuBot { + return { + id: bot.botId.toString(), + shortName: bot.shortName, + icons: bot.icons.map(buildApiAttachMenuIcon).filter(Boolean), + }; +} + +function buildApiAttachMenuIcon(icon: GramJs.AttachMenuBotIcon): ApiAttachMenuBotIcon | undefined { + if (!(icon.icon instanceof GramJs.Document)) return undefined; + + const document = buildApiDocument(icon.icon); + + if (!document) return undefined; + + localDb.documents[String(icon.icon.id)] = icon.icon; + + return { + name: icon.name, + document, + }; +} + function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDocument | undefined { return document ? pick(document, ['url', 'mimeType']) : undefined; } + +export function buildApiBotInfo(botInfo: GramJs.BotInfo): ApiBotInfo { + const { + description, userId, commands, menuButton, + } = botInfo; + + const botId = buildApiPeerId(userId, 'user'); + const commandsArray = commands.map((command) => buildApiBotCommand(botId, command)); + + return { + botId, + description, + menuButton: buildApiBotMenuButton(menuButton), + commands: commandsArray.length ? commandsArray : undefined, + }; +} + +function buildApiBotCommand(botId: string, command: GramJs.BotCommand): ApiBotCommand { + return { + botId, + ...omitVirtualClassFields(command), + }; +} + +export function buildApiBotMenuButton(menuButton: GramJs.TypeBotMenuButton): ApiBotMenuButton { + if (menuButton instanceof GramJs.BotMenuButton) { + return { + type: 'webApp', + text: menuButton.text, + url: menuButton.url, + }; + } + + return { + type: 'commands', + }; +} diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index d39e81d66..606d7c47e 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -149,7 +149,6 @@ function buildApiChatRestrictions(peerEntity: GramJs.TypeUser | GramJs.TypeChat) if (peerEntity instanceof GramJs.Chat) { Object.assign(restrictions, { isNotJoined: peerEntity.left, - isForbidden: peerEntity.kicked, }); } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 67ad93b59..fe566ea1e 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -924,6 +924,9 @@ function buildAction( text = senderId === currentUserId ? 'ActionYouScoredInGame' : 'ActionUserScoredInGame'; translationValues.push('%score%'); score = action.score; + } else if (action instanceof GramJs.MessageActionWebViewDataSent) { + text = 'Notification.WebAppSentData'; + translationValues.push(action.text); } else { text = 'ChatList.UnsupportedMessage'; } @@ -1067,6 +1070,22 @@ function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefi }; } + if (button instanceof GramJs.KeyboardButtonSimpleWebView) { + return { + type: 'simpleWebView', + text, + url: button.url, + }; + } + + if (button instanceof GramJs.KeyboardButtonWebView) { + return { + type: 'webView', + text, + url: button.url, + }; + } + return { type: 'unsupported', text, diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 0e327c405..3a7655f92 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -105,13 +105,15 @@ export function buildApiNotifyException( notifySettings: GramJs.TypePeerNotifySettings, peer: GramJs.TypePeer, serverTimeOffset: number, ) { const { - silent, muteUntil, showPreviews, sound, + silent, muteUntil, showPreviews, otherSound, } = notifySettings; + const hasSound = Boolean(otherSound && !(otherSound instanceof GramJs.NotificationSoundNone)); + return { chatId: getApiChatIdFromMtpPeer(peer), isMuted: silent || (typeof muteUntil === 'number' && getServerTime(serverTimeOffset) < muteUntil), - ...(sound === '' && { isSilent: true }), + ...(!hasSound && { isSilent: true }), ...(showPreviews !== undefined && { shouldShowPreviews: Boolean(showPreviews) }), }; } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 5a181634d..97046a95c 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -1,8 +1,9 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { - ApiBotCommand, ApiUser, ApiUserStatus, ApiUserType, + ApiUser, ApiUserStatus, ApiUserType, } from '../../types'; import { buildApiPeerId } from './peers'; +import { buildApiBotInfo } from './bots'; export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUser { const { @@ -21,8 +22,7 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUse commonChatsCount, pinnedMessageId: pinnedMsgId, isBlocked: Boolean(blocked), - ...(botInfo && { botDescription: botInfo.description }), - ...(botInfo && botInfo.commands.length && { botCommands: buildApiBotCommands(user.id, botInfo) }), + ...(botInfo && { botInfo: buildApiBotInfo(botInfo) }), }, }; } @@ -57,6 +57,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { ...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }), ...(avatarHash && { avatarHash }), ...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }), + ...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachMenuBot: mtpUser.botAttachMenu }), }; } @@ -87,14 +88,6 @@ export function buildApiUserStatus(mtpStatus?: GramJs.TypeUserStatus): ApiUserSt } } -function buildApiBotCommands(botId: string, botInfo: GramJs.BotInfo) { - return botInfo.commands.map(({ command, description }) => ({ - botId, - command, - description, - })) as ApiBotCommand[]; -} - export function buildApiUsersAndStatuses(mtpUsers: GramJs.TypeUser[]) { const userStatusesById: Record = {}; const users: ApiUser[] = []; diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index fc213a6bf..7bba98e90 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -18,6 +18,7 @@ import { ApiSendMessageAction, ApiSticker, ApiVideo, + ApiThemeParameters, } from '../../types'; import localDb from '../localDb'; import { pick } from '../../../util/iteratees'; @@ -466,6 +467,12 @@ export function buildSendMessageAction(action: ApiSendMessageAction) { return undefined; } +export function buildInputThemeParams(params: ApiThemeParameters) { + return new GramJs.DataJSON({ + data: JSON.stringify(params), + }); +} + export function buildMtpPeerId(id: string, type: 'user' | 'chat' | 'channel') { // Workaround for old-fashioned IDs stored locally if (typeof id === 'number') { diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index 41097aaa1..fa55173eb 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -1,16 +1,19 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; -import { ApiChat, ApiUser } from '../../types'; +import { ApiChat, ApiThemeParameters, ApiUser } from '../../types'; import localDb from '../localDb'; import { invokeRequest } from './client'; -import { buildInputPeer, generateRandomBigInt } from '../gramjsBuilders'; +import { buildInputPeer, buildInputThemeParams, generateRandomBigInt } from '../gramjsBuilders'; import { buildApiUser } from '../apiBuilders/users'; -import { buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm } from '../apiBuilders/bots'; +import { + buildApiAttachMenuBot, buildApiBotInlineMediaResult, buildApiBotInlineResult, buildBotSwitchPm, +} from '../apiBuilders/bots'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { addEntitiesWithPhotosToLocalDb, addUserToLocalDb, deserializeBytes } from '../helpers'; import { omitVirtualClassFields } from '../apiBuilders/helpers'; +import { buildCollectionByKey } from '../../../util/iteratees'; export function init() { } @@ -140,6 +143,132 @@ export async function startBot({ }), true); } +export async function requestWebView({ + isSilent, + peer, + bot, + url, + startParam, + replyToMessageId, + theme, + isFromBotMenu, +}: { + isSilent?: boolean; + peer: ApiChat | ApiUser; + bot: ApiUser; + url?: string; + startParam?: string; + replyToMessageId?: number; + theme?: ApiThemeParameters; + isFromBotMenu?: boolean; +}) { + const result = await invokeRequest(new GramJs.messages.RequestWebView({ + silent: isSilent || undefined, + peer: buildInputPeer(peer.id, peer.accessHash), + bot: buildInputPeer(bot.id, bot.accessHash), + replyToMsgId: replyToMessageId, + url, + startParam, + themeParams: theme ? buildInputThemeParams(theme) : undefined, + fromBotMenu: isFromBotMenu || undefined, + })); + + if (result instanceof GramJs.WebViewResultUrl) { + return { + url: result.url, + queryId: result.queryId.toString(), + }; + } + + return undefined; +} + +export async function requestSimpleWebView({ + bot, url, theme, +}: { + bot: ApiUser; + url: string; + theme?: ApiThemeParameters; +}) { + const result = await invokeRequest(new GramJs.messages.RequestSimpleWebView({ + url, + bot: buildInputPeer(bot.id, bot.accessHash), + themeParams: theme ? buildInputThemeParams(theme) : undefined, + })); + + return result?.url; +} + +export function prolongWebView({ + isSilent, + peer, + bot, + queryId, + replyToMessageId, +}: { + isSilent?: boolean; + peer: ApiChat | ApiUser; + bot: ApiUser; + queryId: string; + replyToMessageId?: number; +}) { + return invokeRequest(new GramJs.messages.ProlongWebView({ + silent: isSilent || undefined, + peer: buildInputPeer(peer.id, peer.accessHash), + bot: buildInputPeer(bot.id, bot.accessHash), + queryId: BigInt(queryId), + replyToMsgId: replyToMessageId, + })); +} + +export async function sendWebViewData({ + bot, buttonText, data, +}: { + bot: ApiUser; + buttonText: string; + data: string; +}) { + const randomId = generateRandomBigInt(); + await invokeRequest(new GramJs.messages.SendWebViewData({ + bot: buildInputPeer(bot.id, bot.accessHash), + buttonText, + data, + randomId, + }), true); +} + +export async function loadAttachMenuBots({ + hash, +}: { + hash?: string; +}) { + const result = await invokeRequest(new GramJs.messages.GetAttachMenuBots({ + hash: hash ? BigInt(hash) : undefined, + })); + + if (result instanceof GramJs.AttachMenuBots) { + addEntitiesWithPhotosToLocalDb(result.users); + return { + hash: result.hash.toString(), + bots: buildCollectionByKey(result.bots.map(buildApiAttachMenuBot), 'id'), + }; + } + return undefined; +} + +export function toggleBotInAttachMenu({ + bot, + isEnabled, +}: { + bot: ApiUser; + isEnabled: boolean; +}) { + return invokeRequest(new GramJs.messages.ToggleBotInAttachMenu({ + bot: buildInputPeer(bot.id, bot.accessHash), + enabled: isEnabled, + })); +} + function processInlineBotResult(queryId: string, results: GramJs.TypeBotInlineResult[]) { return results.map((result) => { if (result instanceof GramJs.BotInlineMediaResult) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index b66443665..3386607e2 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -64,6 +64,7 @@ export { export { answerCallbackButton, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults, sendInlineBotResult, startBot, + requestWebView, requestSimpleWebView, sendWebViewData, prolongWebView, loadAttachMenuBots, toggleBotInAttachMenu, } from './bots'; export { diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index 128734db3..68c1dfcf1 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -248,6 +248,9 @@ async function parseMedia( case ApiMediaFormat.Lottie: { return new Blob([data], { type: mimeType }); } + case ApiMediaFormat.Text: { + return data.toString(); + } case ApiMediaFormat.Progressive: { return data.buffer; } diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index f8fa14f6b..c1af376df 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -34,7 +34,7 @@ import { buildApiConfig } from '../apiBuilders/appConfig'; import { addEntitiesWithPhotosToLocalDb } from '../helpers'; const MAX_INT_32 = 2 ** 31 - 1; -const BETA_LANG_CODES = ['ar', 'fa', 'id', 'ko', 'uz']; +const BETA_LANG_CODES = ['ar', 'fa', 'id', 'ko', 'uz', 'en']; export function updateProfile({ firstName, diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 787850ee6..007986f7c 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -44,11 +44,13 @@ import { buildApiNotifyException, buildPrivacyKey, buildPrivacyRules } from './a import { buildApiPhoto } from './apiBuilders/common'; import { buildApiGroupCall, - buildApiGroupCallParticipant, buildPhoneCall, + buildApiGroupCallParticipant, + buildPhoneCall, getGroupCallId, } from './apiBuilders/calls'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; import { buildApiEmojiInteraction } from './apiBuilders/symbols'; +import { buildApiBotMenuButton } from './apiBuilders/bots'; type Update = ( (GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] } @@ -915,6 +917,23 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { callId: update.phoneCallId.toString(), data: Array.from(update.data), }); + } else if (update instanceof GramJs.UpdateWebViewResultSent) { + const { queryId } = update; + + onUpdate({ + '@type': 'updateWebViewResultSent', + queryId: queryId.toString(), + }); + } else if (update instanceof GramJs.UpdateBotMenuButton) { + const { botId, button } = update; + + const id = buildApiPeerId(botId, 'user'); + + onUpdate({ + '@type': 'updateBotMenuButton', + botId: id, + button: buildApiBotMenuButton(button), + }); } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; // eslint-disable-next-line no-console diff --git a/src/api/types/bots.ts b/src/api/types/bots.ts index b0bb29e4a..c5166c8e1 100644 --- a/src/api/types/bots.ts +++ b/src/api/types/bots.ts @@ -44,3 +44,22 @@ export interface ApiBotCommand { command: string; description: string; } + +type ApiBotMenuButtonCommands = { + type: 'commands'; +}; + +type ApiBotMenuButtonWebApp = { + type: 'webApp'; + text: string; + url: string; +}; + +export type ApiBotMenuButton = ApiBotMenuButtonWebApp | ApiBotMenuButtonCommands; + +export interface ApiBotInfo { + botId: string; + commands?: ApiBotCommand[]; + description: string; + menuButton: ApiBotMenuButton; +} diff --git a/src/api/types/media.ts b/src/api/types/media.ts index 2a19b9411..6cfb51b2d 100644 --- a/src/api/types/media.ts +++ b/src/api/types/media.ts @@ -6,6 +6,7 @@ export enum ApiMediaFormat { Lottie, Progressive, Stream, + Text, } export type ApiParsedMedia = string | Blob | ArrayBuffer; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index c7b065ece..e7d272255 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -395,6 +395,18 @@ interface ApiKeyboardButtonUrl { url: string; } +interface ApiKeyboardButtonSimpleWebView { + type: 'simpleWebView'; + text: string; + url: string; +} + +interface ApiKeyboardButtonWebView { + type: 'webView'; + text: string; + url: string; +} + interface ApiKeyboardButtonCallback { type: 'callback'; text: string; @@ -407,7 +419,7 @@ interface ApiKeyboardButtonRequestPoll { isQuiz?: boolean; } -interface ApiKeyboardButtonSwitchInline { +interface ApiKeyboardButtonSwitchBotInline { type: 'switchBotInline'; text: string; query: string; @@ -426,8 +438,10 @@ export type ApiKeyboardButton = ( | ApiKeyboardButtonUrl | ApiKeyboardButtonCallback | ApiKeyboardButtonRequestPoll - | ApiKeyboardButtonSwitchInline + | ApiKeyboardButtonSwitchBotInline | ApiKeyboardButtonUserProfile + | ApiKeyboardButtonWebView + | ApiKeyboardButtonSimpleWebView ); export type ApiKeyboardButtons = ApiKeyboardButton[][]; @@ -448,6 +462,15 @@ export type ApiSendMessageAction = { type: 'cancel' | 'typing' | 'recordAudio' | 'chooseSticker' | 'playingGame'; }; +export type ApiThemeParameters = { + bg_color: string; + text_color: string; + hint_color: string; + link_color: string; + button_color: string; + button_text_color: string; +}; + export const MAIN_THREAD_ID = -1; // `Symbol` can not be transferred from worker diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 662f6f7a5..095aa4c6d 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -22,6 +22,7 @@ import { import { ApiGroupCall, ApiPhoneCall, } from './calls'; +import { ApiBotMenuButton } from './bots'; export type ApiUpdateReady = { '@type': 'updateApiReady'; @@ -486,6 +487,17 @@ export type ApiUpdatePhoneCallConnectionState = { connectionState: RTCPeerConnectionState; }; +export type ApiUpdateWebViewResultSent = { + '@type': 'updateWebViewResultSent'; + queryId: string; +}; + +export type ApiUpdateBotMenuButton = { + '@type': 'updateBotMenuButton'; + botId: string; + button: ApiBotMenuButton; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -501,14 +513,14 @@ export type ApiUpdate = ( ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateNewScheduledMessage | ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | - ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | + ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | ApiUpdateWebViewResultSent | ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy | ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted | ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState | - ApiUpdatePhoneCallConnectionState + ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 6a15616af..e5e213acd 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -1,5 +1,5 @@ -import { ApiPhoto } from './messages'; -import { ApiBotCommand } from './bots'; +import { ApiDocument, ApiPhoto } from './messages'; +import { ApiBotInfo } from './bots'; export interface ApiUser { id: string; @@ -24,6 +24,7 @@ export interface ApiUser { isFullyLoaded: boolean; }; fakeType?: ApiFakeType; + isAttachMenuBot?: boolean; // Obtained from GetFullUser / UserFullInfo fullInfo?: ApiUserFullInfo; @@ -33,9 +34,8 @@ export interface ApiUserFullInfo { isBlocked?: boolean; bio?: string; commonChatsCount?: number; - botDescription?: string; pinnedMessageId?: number; - botCommands?: ApiBotCommand[]; + botInfo?: ApiBotInfo; } export type ApiFakeType = 'fake' | 'scam'; @@ -50,3 +50,14 @@ export interface ApiUserStatus { wasOnline?: number; expires?: number; } + +export interface ApiAttachMenuBot { + id: string; + shortName: string; + icons: ApiAttachMenuBotIcon[]; +} + +export interface ApiAttachMenuBotIcon { + name: string; + document: ApiDocument; +} diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index fce9bb749..05dc2abc6 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index cdaca717b..160709892 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 495cf98f6..bed7b09ad 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -6,6 +6,9 @@ export { default as Notifications } from '../components/main/Notifications'; export { default as SafeLinkModal } from '../components/main/SafeLinkModal'; export { default as HistoryCalendar } from '../components/main/HistoryCalendar'; export { default as NewContactModal } from '../components/main/NewContactModal'; +export { default as WebAppModal } from '../components/main/WebAppModal'; +export { default as BotTrustModal } from '../components/main/BotTrustModal'; +export { default as BotAttachModal } from '../components/main/BotAttachModal'; export { default as CalendarModal } from '../components/common/CalendarModal'; export { default as DeleteMessageModal } from '../components/common/DeleteMessageModal'; diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index b0931d43f..0c56afa13 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -54,7 +54,7 @@ const EmbeddedMessage: FC = ({ const lang = useLang(); - const senderTitle = message?.forwardInfo?.hiddenUserName || (sender && getSenderTitle(lang, sender)); + const senderTitle = sender ? getSenderTitle(lang, sender) : message?.forwardInfo?.hiddenUserName; return (
= (props) => { + const { bot } = props; + const BotAttachModal = useModuleLoader(Bundles.Extra, 'BotAttachModal', !bot); + + // eslint-disable-next-line react/jsx-props-no-spreading + return BotAttachModal ? : undefined; +}; + +export default memo(BotAttachModalAsync); diff --git a/src/components/main/BotAttachModal.tsx b/src/components/main/BotAttachModal.tsx new file mode 100644 index 000000000..031d99cf3 --- /dev/null +++ b/src/components/main/BotAttachModal.tsx @@ -0,0 +1,34 @@ +import React, { FC } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import { ApiUser } from '../../api/types'; + +import useLang from '../../hooks/useLang'; + +import ConfirmDialog from '../ui/ConfirmDialog'; + +export type OwnProps = { + bot?: ApiUser; +}; + +const BotAttachModal: FC = ({ + bot, +}) => { + const { closeBotAttachRequestModal, confirmBotAttachRequest } = getActions(); + + const lang = useLang(); + + const name = bot?.firstName; + + return ( + + ); +}; + +export default BotAttachModal; diff --git a/src/components/main/BotTrustModal.async.tsx b/src/components/main/BotTrustModal.async.tsx new file mode 100644 index 000000000..b5e4c2b91 --- /dev/null +++ b/src/components/main/BotTrustModal.async.tsx @@ -0,0 +1,16 @@ +import React, { FC, memo } from '../../lib/teact/teact'; +import { Bundles } from '../../util/moduleLoader'; + +import { OwnProps } from './BotTrustModal'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const BotTrustModalAsync: FC = (props) => { + const { bot } = props; + const BotTrustModal = useModuleLoader(Bundles.Extra, 'BotTrustModal', !bot); + + // eslint-disable-next-line react/jsx-props-no-spreading + return BotTrustModal ? : undefined; +}; + +export default memo(BotTrustModalAsync); diff --git a/src/components/main/BotTrustModal.tsx b/src/components/main/BotTrustModal.tsx new file mode 100644 index 000000000..f26152ffd --- /dev/null +++ b/src/components/main/BotTrustModal.tsx @@ -0,0 +1,47 @@ +import React, { FC, memo, useCallback } from '../../lib/teact/teact'; +import { getActions } from '../../global'; + +import { ApiUser } from '../../api/types'; + +import { getUserFullName } from '../../global/helpers'; +import renderText from '../common/helpers/renderText'; + +import useLang from '../../hooks/useLang'; +import usePrevious from '../../hooks/usePrevious'; + +import ConfirmDialog from '../ui/ConfirmDialog'; + +export type OwnProps = { + bot?: ApiUser; + type?: 'game' | 'webApp'; +}; + +const BotTrustModal: FC = ({ bot, type }) => { + const { cancelBotTrustRequest, markBotTrusted } = getActions(); + const lang = useLang(); + // Keep props a little bit longer, to show correct text on closing animation + const previousBot = usePrevious(bot, false); + const previousType = usePrevious(type, false); + const currentBot = bot || previousBot; + const currentType = type || previousType; + + const handleBotTrustAccept = useCallback(() => { + markBotTrusted({ botId: bot!.id }); + }, [markBotTrusted, bot]); + + const title = currentType === 'game' ? lang('AppName') : lang('BotOpenPageTitle'); + const text = currentType === 'game' ? lang('BotPermissionGameAlert', getUserFullName(currentBot)) + : lang('BotOpenPageMessage', getUserFullName(currentBot)); + + return ( + + ); +}; + +export default memo(BotTrustModal); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index c58f85b35..d8609f365 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -4,7 +4,9 @@ import React, { import { getActions, withGlobal } from '../../global'; import { LangCode } from '../../types'; -import { ApiMessage, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType } from '../../api/types'; +import { + ApiChat, ApiMessage, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType, +} from '../../api/types'; import { GlobalState } from '../../global/types'; import '../../global/actions/all'; @@ -54,10 +56,14 @@ import ActiveCallHeader from '../calls/ActiveCallHeader.async'; import PhoneCall from '../calls/phone/PhoneCall.async'; import NewContactModal from './NewContactModal.async'; import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async'; +import WebAppModal from './WebAppModal.async'; +import BotTrustModal from './BotTrustModal.async'; +import BotAttachModal from './BotAttachModal.async'; import './Main.scss'; type StateProps = { + chat?: ApiChat; connectionState?: ApiUpdateConnectionStateType; authState?: ApiUpdateAuthorizationStateType; lastSyncTime?: number; @@ -84,6 +90,9 @@ type StateProps = { openedGame?: GlobalState['openedGame']; gameTitle?: string; isRatePhoneCallModalOpen?: boolean; + webApp?: GlobalState['webApp']; + botTrustRequest?: GlobalState['botTrustRequest']; + botAttachRequest?: GlobalState['botAttachRequest']; }; const NOTIFICATION_INTERVAL = 1000; @@ -120,6 +129,9 @@ const Main: FC = ({ openedGame, gameTitle, isRatePhoneCallModalOpen, + botTrustRequest, + botAttachRequest, + webApp, }) => { const { sync, @@ -138,6 +150,7 @@ const Main: FC = ({ openStickerSetShortName, checkVersionNotification, loadAppConfig, + loadAttachMenuBots, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -163,10 +176,11 @@ const Main: FC = ({ loadNotificationExceptions(); loadTopInlineBots(); loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG }); + loadAttachMenuBots(); } }, [ lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings, - loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, + loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachMenuBots, ]); // Language-based API calls @@ -366,10 +380,13 @@ const Main: FC = ({ isByPhoneNumber={newContactByPhoneNumber} /> + + +
); }; @@ -433,6 +450,9 @@ export default memo(withGlobal( openedGame, gameTitle, isRatePhoneCallModalOpen: Boolean(global.ratingPhoneCall), + botTrustRequest: global.botTrustRequest, + botAttachRequest: global.botAttachRequest, + webApp: global.webApp, }; }, )(Main)); diff --git a/src/components/main/WebAppModal.async.tsx b/src/components/main/WebAppModal.async.tsx new file mode 100644 index 000000000..9ffb9494b --- /dev/null +++ b/src/components/main/WebAppModal.async.tsx @@ -0,0 +1,16 @@ +import React, { FC, memo } from '../../lib/teact/teact'; +import { Bundles } from '../../util/moduleLoader'; + +import { OwnProps } from './WebAppModal'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const WebAppModalAsync: FC = (props) => { + const { webApp } = props; + const WebAppModal = useModuleLoader(Bundles.Extra, 'WebAppModal', !webApp); + + // eslint-disable-next-line react/jsx-props-no-spreading + return WebAppModal ? : undefined; +}; + +export default memo(WebAppModalAsync); diff --git a/src/components/main/WebAppModal.scss b/src/components/main/WebAppModal.scss new file mode 100644 index 000000000..08436280d --- /dev/null +++ b/src/components/main/WebAppModal.scss @@ -0,0 +1,69 @@ +.WebAppModal { + .modal-header { + border-bottom: 1px solid var(--color-dividers); + padding: 0.5rem; + } + + .modal-dialog { + height: 75%; + justify-content: center; + border: none; + box-shadow: none; + margin: 0; + overflow: hidden; + } + + .modal-content { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; + border-bottom-right-radius: var(--border-radius-default); + border-bottom-left-radius: var(--border-radius-default); + } + + .web-app-frame { + width: 100%; + height: 100%; + border: 0; + + &.with-button { + height: calc(100% - 56px); + } + } + + .web-app-button { + position: absolute; + bottom: 0; + border-radius: 0; + transform: translateY(100%); + transition: 0.25s ease-in-out transform; + + &.visible { + transform: translateY(0); + } + + &.hidden { + visibility: hidden; + } + } + + .Spinner { + position: absolute; + right: 1rem; + } + + @media (max-width: 600px) { + .modal-dialog { + background-color: var(--color-background); + border-radius: 0; + height: 100%; + max-width: 100% !important; + } + + .modal-content { + max-height: none; + border-radius: 0; + } + } +} diff --git a/src/components/main/WebAppModal.tsx b/src/components/main/WebAppModal.tsx new file mode 100644 index 000000000..c79d9bfa6 --- /dev/null +++ b/src/components/main/WebAppModal.tsx @@ -0,0 +1,292 @@ +import React, { + FC, memo, useCallback, useEffect, useMemo, useRef, useState, +} from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import { ApiChat } from '../../api/types'; +import { GlobalState } from '../../global/types'; +import { ThemeKey } from '../../types'; + +import windowSize from '../../util/windowSize'; +import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; +import { selectCurrentChat, selectTheme } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import { extractCurrentThemeParams, validateHexColor } from '../../util/themeStyle'; + +import useInterval from '../../hooks/useInterval'; +import useLang from '../../hooks/useLang'; +import useOnChange from '../../hooks/useOnChange'; +import useWebAppFrame, { WebAppInboundEvent } from './hooks/useWebAppFrame'; +import usePrevious from '../../hooks/usePrevious'; + +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; +import DropdownMenu from '../ui/DropdownMenu'; +import MenuItem from '../ui/MenuItem'; +import Spinner from '../ui/Spinner'; + +import './WebAppModal.scss'; + +type WebAppButton = { + isVisible: boolean; + isActive: boolean; + text: string; + color: string; + textColor: string; + isProgressVisible: boolean; +}; + +export type OwnProps = { + webApp?: GlobalState['webApp']; +}; + +type StateProps = { + isInstalled?: boolean; + chat?: ApiChat; + theme?: ThemeKey; +}; + +const MAIN_BUTTON_ANIMATION_TIME = 250; +const PROLONG_INTERVAL = 45000; // 45s +const ANIMATION_WAIT = 400; + +const WebAppModal: FC = ({ + webApp, + chat, + isInstalled, + theme, +}) => { + const { + closeWebApp, sendWebViewData, prolongWebView, toggleBotInAttachMenu, + } = getActions(); + const [mainButton, setMainButton] = useState(); + const lang = useLang(); + const { + url, bot, buttonText, queryId, + } = webApp || {}; + const isOpen = Boolean(url); + const isSimple = !queryId; + + const handleEvent = useCallback((event: WebAppInboundEvent) => { + const { eventType } = event; + if (eventType === 'web_app_close') { + closeWebApp(); + } + + if (eventType === 'web_app_data_send') { + const { eventData } = event; + closeWebApp(); + sendWebViewData({ + bot: bot!, + buttonText: buttonText!, + data: eventData.data, + }); + } + + if (eventType === 'web_app_setup_main_button') { + const { eventData } = event; + const themeParams = extractCurrentThemeParams(); + // Validate colors if they are present + const color = !eventData.color || validateHexColor(eventData.color) ? eventData.color + : themeParams.button_color; + const textColor = !eventData.text_color || validateHexColor(eventData.text_color) ? eventData.text_color + : themeParams.text_color; + setMainButton({ + isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length), + isActive: eventData.is_active, + text: eventData.text || '', + color, + textColor, + isProgressVisible: eventData.is_progress_visible, + }); + } + }, [bot, buttonText, closeWebApp, sendWebViewData]); + + const { + ref, reloadFrame, sendEvent, sendViewport, sendTheme, + } = useWebAppFrame(isOpen, isSimple, handleEvent); + + const shouldShowMainButton = mainButton?.isVisible && mainButton.text.trim().length > 0; + + useInterval(() => { + prolongWebView({ + bot: bot!, + queryId: queryId!, + peer: chat!, + }); + }, queryId ? PROLONG_INTERVAL : undefined, true); + + const handleMainButtonClick = useCallback(() => { + sendEvent({ + eventType: 'main_button_pressed', + }); + }, [sendEvent]); + + const handleRefreshClick = useCallback(() => { + reloadFrame(webApp!.url); + }, [reloadFrame, webApp]); + + // Notify view that height changed + useOnChange(() => { + setTimeout(() => { + sendViewport(); + }, ANIMATION_WAIT); + }, [mainButton?.isVisible, sendViewport]); + + // Notify view that theme changed + useOnChange(() => { + setTimeout(() => { + sendTheme(); + }, ANIMATION_WAIT); + }, [theme, sendTheme]); + + // Prevent refresh when rotating device + useEffect(() => { + if (!isOpen) return undefined; + windowSize.disableRefresh(); + + return () => { + windowSize.enableRefresh(); + }; + }, [isOpen]); + + const handleToggleClick = useCallback(() => { + toggleBotInAttachMenu({ + botId: bot!.id, + isEnabled: !isInstalled, + }); + }, [bot, isInstalled, toggleBotInAttachMenu]); + + const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen: isMenuOpen }) => ( + + ); + }, []); + + const header = useMemo(() => { + return ( +
+ +
{bot?.firstName}
+ + {lang('WebApp.ReloadPage')} + {bot?.isAttachMenuBot && ( + + {lang(isInstalled ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')} + + )} + +
+ ); + }, [ + MoreMenuButton, bot, closeWebApp, handleRefreshClick, handleToggleClick, lang, isInstalled, + ]); + + const prevMainButtonColor = usePrevious(mainButton?.color, true); + const prevMainButtonTextColor = usePrevious(mainButton?.textColor, true); + const prevMainButtonIsActive = usePrevious(mainButton && Boolean(mainButton.isActive), true); + const prevMainButtonText = usePrevious(mainButton?.text, true); + + const mainButtonCurrentColor = mainButton?.color || prevMainButtonColor; + const mainButtonCurrentTextColor = mainButton?.textColor || prevMainButtonTextColor; + const mainButtonCurrentIsActive = mainButton?.isActive !== undefined ? mainButton.isActive : prevMainButtonIsActive; + const mainButtonCurrentText = mainButton?.text || prevMainButtonText; + + useEffect(() => { + if (!isOpen) setMainButton(undefined); + }, [isOpen]); + + const [shouldDecreaseWebFrameSize, setShouldDecreaseWebFrameSize] = useState(false); + const [shouldHideButton, setShouldHideButton] = useState(true); + + const buttonChangeTimeout = useRef>(); + + useEffect(() => { + if (buttonChangeTimeout.current) clearTimeout(buttonChangeTimeout.current); + if (!shouldShowMainButton) { + setShouldDecreaseWebFrameSize(false); + buttonChangeTimeout.current = setTimeout(() => { + setShouldHideButton(true); + }, MAIN_BUTTON_ANIMATION_TIME); + } else { + setShouldHideButton(false); + buttonChangeTimeout.current = setTimeout(() => { + setShouldDecreaseWebFrameSize(true); + }, MAIN_BUTTON_ANIMATION_TIME); + } + }, [setShouldDecreaseWebFrameSize, shouldShowMainButton]); + + return ( + + {isOpen && ( + <> +