From d7226fded9d288ae48912163aeb5cd665263bbd8 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 8 Apr 2022 20:59:24 +0200 Subject: [PATCH] Implement Games (#1797) --- src/api/gramjs/apiBuilders/messages.ts | 172 ++++++++++++++---- src/api/gramjs/gramjsBuilders/index.ts | 5 + src/api/gramjs/helpers.ts | 9 + src/api/gramjs/methods/bots.ts | 7 +- src/api/gramjs/methods/messages.ts | 5 +- src/api/types/messages.ts | 69 ++++++- .../helpers/renderActionMessageText.tsx | 16 +- src/components/main/ForwardPicker.tsx | 17 +- src/components/main/GameModal.scss | 48 +++++ src/components/main/GameModal.tsx | 95 ++++++++++ src/components/main/Main.tsx | 12 ++ .../middle/composer/BotKeyboardMenu.tsx | 6 +- src/components/middle/composer/Composer.tsx | 26 ++- .../middle/composer/MessageInput.tsx | 2 +- src/components/middle/composer/PollModal.tsx | 13 +- src/components/middle/message/Game.scss | 34 ++++ src/components/middle/message/Game.tsx | 84 +++++++++ .../middle/message/InlineButtons.tsx | 12 +- src/components/middle/message/Message.tsx | 14 +- src/components/ui/Skeleton.scss | 3 + src/components/ui/Skeleton.tsx | 6 +- src/global/actions/api/bots.ts | 134 +++++++++++--- src/global/actions/api/chats.ts | 24 +++ src/global/actions/api/messages.ts | 5 +- src/global/actions/ui/messages.ts | 17 +- src/global/actions/ui/misc.ts | 19 ++ src/global/cache.ts | 6 + src/global/helpers/messageMedia.ts | 22 ++- src/global/helpers/messageSummary.ts | 5 + src/global/helpers/messages.ts | 4 +- src/global/initialState.ts | 4 + src/global/selectors/chats.ts | 7 + src/global/selectors/messages.ts | 4 +- src/global/types.ts | 65 ++++++- src/hooks/useSendMessageAction.ts | 2 +- src/lib/gramjs/client/auth.ts | 1 - src/util/PopupManager.ts | 45 +++++ 37 files changed, 914 insertions(+), 105 deletions(-) create mode 100644 src/components/main/GameModal.scss create mode 100644 src/components/main/GameModal.tsx create mode 100644 src/components/middle/message/Game.scss create mode 100644 src/components/middle/message/Game.tsx create mode 100644 src/util/PopupManager.ts diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index a4b2aa386..64bd4aff4 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -29,6 +29,7 @@ import { ApiSponsoredMessage, ApiUser, ApiLocation, + ApiGame, } from '../../types'; import { @@ -327,6 +328,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes const location = buildLocationFromMedia(media); if (location) return { location }; + const game = buildGameFromMedia(media); + if (game) return { game }; + return undefined; } @@ -532,6 +536,16 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u } } else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) { mediaType = 'video'; + const videoAttribute = attributes + .find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo); + + if (videoAttribute) { + const { w: width, h: height } = videoAttribute; + mediaSize = { + width, + height, + }; + } } } @@ -638,6 +652,33 @@ function buildGeoPoint(geo: GramJs.TypeGeoPoint): ApiLocation['geo'] | undefined }; } +function buildGameFromMedia(media: GramJs.TypeMessageMedia): ApiGame | undefined { + if (!(media instanceof GramJs.MessageMediaGame)) { + return undefined; + } + + return buildGame(media); +} + +function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined { + const { + id, accessHash, shortName, title, description, photo: apiPhoto, document: apiDocument, + } = media.game; + + const photo = apiPhoto instanceof GramJs.Photo ? buildApiPhoto(apiPhoto) : undefined; + const document = apiDocument instanceof GramJs.Document ? buildApiDocument(apiDocument) : undefined; + + return { + id: id.toString(), + accessHash: accessHash.toString(), + shortName, + title, + description, + photo, + document, + }; +} + export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll { const { id, answers: rawAnswers } = poll; const answers = rawAnswers.map((answer) => ({ @@ -751,6 +792,7 @@ function buildAction( const translationValues = []; let type: ApiAction['type'] = 'other'; let photo: ApiPhoto | undefined; + let score: number | undefined; const targetUserIds = 'users' in action ? action.users && action.users.map((id) => buildApiPeerId(id, 'user')) @@ -868,6 +910,10 @@ function buildAction( } else if (action instanceof GramJs.MessageActionChatJoinedByRequest) { text = 'ChatService.UserJoinedGroupByRequest'; translationValues.push('%action_origin%'); + } else if (action instanceof GramJs.MessageActionGameScore) { + text = senderId === currentUserId ? 'ActionYouScoredInGame' : 'ActionUserScoredInGame'; + translationValues.push('%score%'); + score = action.score; } else { text = 'ChatList.UnsupportedMessage'; } @@ -887,21 +933,22 @@ function buildAction( currency, translationValues, call, + score, }; } function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefined { - const { id: messageId, replyMarkup, media } = message; + const { replyMarkup, media } = message; + // TODO Move to the proper button inside preview if (!replyMarkup) { if (media instanceof GramJs.MessageMediaWebPage && media.webpage instanceof GramJs.WebPage) { if (media.webpage.type === 'telegram_message') { return { inlineButtons: [[{ - type: 'url' as const, + type: 'url', text: 'Show Message', - messageId, - value: media.webpage.url, + url: media.webpage.url, }]], }; } @@ -916,40 +963,103 @@ function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefi } const markup = replyMarkup.rows.map(({ buttons }) => { - return buttons.map((button) => { - let { text } = button; + return buttons.map((button): ApiKeyboardButton => { + const { text } = button; - let type; - let value; if (button instanceof GramJs.KeyboardButton) { - type = 'command'; - value = text; - } else if (button instanceof GramJs.KeyboardButtonUrl) { - type = 'url'; - value = button.url; - } else if (button instanceof GramJs.KeyboardButtonCallback) { - type = 'callback'; - value = serializeBytes(button.data); - } else if (button instanceof GramJs.KeyboardButtonRequestPoll) { - type = 'requestPoll'; - } else if (button instanceof GramJs.KeyboardButtonRequestPhone) { - type = 'requestSelfContact'; - } else if (button instanceof GramJs.KeyboardButtonBuy) { - if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) { - text = 'PaymentReceipt'; - value = media.receiptMsgId; + return { + type: 'command', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonUrl) { + if (button.url.includes('?startgroup=')) { + return { + type: 'unsupported', + text, + }; } - type = 'buy'; - } else { - type = 'NOT_SUPPORTED'; + + return { + type: 'url', + text, + url: button.url, + }; + } + + if (button instanceof GramJs.KeyboardButtonCallback) { + if (button.requiresPassword) { + return { + type: 'unsupported', + text, + }; + } + + return { + type: 'callback', + text, + data: serializeBytes(button.data), + }; + } + + if (button instanceof GramJs.KeyboardButtonRequestPoll) { + return { + type: 'requestPoll', + text, + isQuiz: button.quiz, + }; + } + + if (button instanceof GramJs.KeyboardButtonRequestPhone) { + return { + type: 'requestPhone', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonBuy) { + if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) { + return { + type: 'receipt', + text: 'PaymentReceipt', + receiptMessageId: media.receiptMsgId, + }; + } + return { + type: 'buy', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonGame) { + return { + type: 'game', + text, + }; + } + + if (button instanceof GramJs.KeyboardButtonSwitchInline) { + return { + type: 'switchBotInline', + text, + query: button.query, + isSamePeer: button.samePeer, + }; + } + + if (button instanceof GramJs.KeyboardButtonUserProfile) { + return { + type: 'userProfile', + text, + userId: button.userId.toString(), + }; } return { - type, + type: 'unsupported', text, - messageId, - value, - } as ApiKeyboardButton; + }; }); }); diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 29dbdc9df..de5071d42 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -319,6 +319,9 @@ export function isMessageWithMedia(message: GramJs.Message | GramJs.UpdateServic && media.webpage.document.mimeType.startsWith('video') ) ) + ) || ( + media instanceof GramJs.MessageMediaGame + && (media.game.document instanceof GramJs.Document || media.game.photo instanceof GramJs.Photo) ) ); } @@ -441,6 +444,8 @@ export function buildSendMessageAction(action: ApiSendMessageAction) { return new GramJs.SendMessageRecordAudioAction(); case 'chooseSticker': return new GramJs.SendMessageChooseStickerAction(); + case 'playingGame': + return new GramJs.SendMessageGamePlayAction(); } return undefined; } diff --git a/src/api/gramjs/helpers.ts b/src/api/gramjs/helpers.ts index 7f10b1123..1755e504b 100644 --- a/src/api/gramjs/helpers.ts +++ b/src/api/gramjs/helpers.ts @@ -31,6 +31,15 @@ export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageServ localDb.documents[String(message.media.webpage.document.id)] = message.media.webpage.document; } + if (message instanceof GramJs.Message + && message.media instanceof GramJs.MessageMediaGame + ) { + if (message.media.game.document instanceof GramJs.Document) { + localDb.documents[String(message.media.game.document.id)] = message.media.game.document; + } + addPhotoToLocalDb(message.media.game.photo); + } + if (message instanceof GramJs.MessageService && 'photo' in message.action) { addPhotoToLocalDb(message.action.photo); } diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index 92d5f1de5..41097aaa1 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -16,14 +16,15 @@ export function init() { } export async function answerCallbackButton({ - chatId, accessHash, messageId, data, + chatId, accessHash, messageId, data, isGame, }: { - chatId: string; accessHash?: string; messageId: number; data: string; + chatId: string; accessHash?: string; messageId: number; data?: string; isGame?: boolean; }) { const result = await invokeRequest(new GramJs.messages.GetBotCallbackAnswer({ peer: buildInputPeer(chatId, accessHash), msgId: messageId, - data: deserializeBytes(data), + data: data ? deserializeBytes(data) : undefined, + game: isGame || undefined, })); return result ? omitVirtualClassFields(result) : undefined; diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 00a90faf3..f541dc06c 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1102,6 +1102,7 @@ export async function forwardMessages({ isSilent, scheduledAt, sendAs, + withMyScore, }: { fromChat: ApiChat; toChat: ApiChat; @@ -1110,6 +1111,7 @@ export async function forwardMessages({ isSilent?: boolean; scheduledAt?: number; sendAs?: ApiUser | ApiChat; + withMyScore?: boolean; }) { const messageIds = messages.map(({ id }) => id); const randomIds = messages.map(generateRandomBigInt); @@ -1131,7 +1133,8 @@ export async function forwardMessages({ toPeer: buildInputPeer(toChat.id, toChat.accessHash), randomId: randomIds, id: messageIds, - ...(isSilent && { sil2ent: isSilent }), + withMyScore: withMyScore || undefined, + silent: isSilent || undefined, ...(scheduledAt && { scheduleDate: scheduledAt }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), }), true); diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 7071573cd..6068ba2cd 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -174,6 +174,16 @@ interface ApiGeoLive { export type ApiLocation = ApiGeo | ApiVenue | ApiGeoLive; +export type ApiGame = { + title: string; + description: string; + photo?: ApiPhoto; + shortName: string; + id: string; + accessHash: string; + document?: ApiDocument; +}; + export type ApiNewPoll = { summary: ApiPoll['summary']; quiz?: { @@ -193,6 +203,7 @@ export interface ApiAction { currency?: string; translationValues: string[]; call?: Partial; + score?: number; } export interface ApiWebPage { @@ -272,6 +283,7 @@ export interface ApiMessage { voice?: ApiVoice; invoice?: ApiInvoice; location?: ApiLocation; + game?: ApiGame; }; date: number; isOutgoing: boolean; @@ -363,13 +375,60 @@ export type ApiSponsoredMessage = { expiresAt: number; }; -export interface ApiKeyboardButton { - type: 'command' | 'url' | 'callback' | 'requestPoll' | 'requestSelfContact' | 'buy' | 'NOT_SUPPORTED'; +// KeyboardButtons + +interface ApiKeyboardButtonSimple { + type: 'unsupported' | 'buy' | 'command' | 'requestPhone' | 'game'; text: string; - messageId: number; - value?: string; } +interface ApiKeyboardButtonReceipt { + type: 'receipt'; + text: string; + receiptMessageId: number; +} + +interface ApiKeyboardButtonUrl { + type: 'url'; + text: string; + url: string; +} + +interface ApiKeyboardButtonCallback { + type: 'callback'; + text: string; + data: string; +} + +interface ApiKeyboardButtonRequestPoll { + type: 'requestPoll'; + text: string; + isQuiz?: boolean; +} + +interface ApiKeyboardButtonSwitchInline { + type: 'switchBotInline'; + text: string; + query: string; + isSamePeer?: boolean; +} + +interface ApiKeyboardButtonUserProfile { + type: 'userProfile'; + text: string; + userId: string; +} + +export type ApiKeyboardButton = ( + ApiKeyboardButtonSimple + | ApiKeyboardButtonReceipt + | ApiKeyboardButtonUrl + | ApiKeyboardButtonCallback + | ApiKeyboardButtonRequestPoll + | ApiKeyboardButtonSwitchInline + | ApiKeyboardButtonUserProfile +); + export type ApiKeyboardButtons = ApiKeyboardButton[][]; export type ApiReplyKeyboard = { keyboardPlaceholder?: string; @@ -385,7 +444,7 @@ export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse' | 'copyright' | 'geoIrrelevant' | 'fake' | 'illegalDrugs' | 'personalDetails' | 'other'; export type ApiSendMessageAction = { - type: 'cancel' | 'typing' | 'recordAudio' | 'chooseSticker'; + type: 'cancel' | 'typing' | 'recordAudio' | 'chooseSticker' | 'playingGame'; }; export const MAIN_THREAD_ID = -1; diff --git a/src/components/common/helpers/renderActionMessageText.tsx b/src/components/common/helpers/renderActionMessageText.tsx index 448b83019..cf2b8a0f0 100644 --- a/src/components/common/helpers/renderActionMessageText.tsx +++ b/src/components/common/helpers/renderActionMessageText.tsx @@ -42,15 +42,17 @@ export function renderActionMessageText( } const { - text, translationValues, amount, currency, call, + text, translationValues, amount, currency, call, score, } = message.content.action; const content: TextPart[] = []; const noLinks = options.asPlainText || options.asTextWithSpoilers; const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage ? 'Message.PinnedGenericMessage' : text; - let unprocessed = lang(translationKey, translationValues?.length ? translationValues : undefined); + if (translationKey.includes('ScoredInGame')) { // Translation hack for games + unprocessed = unprocessed.replace('un1', '%action_origin%').replace('un2', '%message%'); + } let processed: TextPart[]; if (unprocessed.includes('%payment_amount%')) { @@ -76,6 +78,16 @@ export function renderActionMessageText( unprocessed = processed.pop() as string; content.push(...processed); + if (unprocessed.includes('%score%')) { + processed = processPlaceholder( + unprocessed, + '%score%', + score!.toString(), + ); + unprocessed = processed.pop() as string; + content.push(...processed); + } + processed = processPlaceholder( unprocessed, '%target_user%', diff --git a/src/components/main/ForwardPicker.tsx b/src/components/main/ForwardPicker.tsx index 11c205f24..4f24e4e5a 100644 --- a/src/components/main/ForwardPicker.tsx +++ b/src/components/main/ForwardPicker.tsx @@ -4,6 +4,7 @@ import React, { import { getActions, getGlobal, withGlobal } from '../../global'; import { ApiChat, MAIN_THREAD_ID } from '../../api/types'; +import { GlobalState } from '../../global/types'; import { filterChatsByName, @@ -29,6 +30,7 @@ type StateProps = { pinnedIds?: string[]; contactIds?: string[]; currentUserId?: string; + switchBotInline?: GlobalState['switchBotInline']; }; const ForwardPicker: FC = ({ @@ -39,10 +41,13 @@ const ForwardPicker: FC = ({ contactIds, currentUserId, isOpen, + switchBotInline, }) => { const { setForwardChatId, exitForwardMode, + openChatWithText, + resetSwitchBotInline, } = getActions(); const lang = useLang(); @@ -86,8 +91,14 @@ const ForwardPicker: FC = ({ }, [activeListIds, archivedListIds, chatsById, contactIds, currentUserId, filter, isOpen, lang, pinnedIds]); const handleSelectUser = useCallback((userId: string) => { - setForwardChatId({ id: userId }); - }, [setForwardChatId]); + if (switchBotInline) { + const text = `@${switchBotInline.botUsername} ${switchBotInline.query}`; + openChatWithText({ chatId: userId, text }); + resetSwitchBotInline(); + } else { + setForwardChatId({ id: userId }); + } + }, [openChatWithText, resetSwitchBotInline, setForwardChatId, switchBotInline]); const renderingChatAndContactIds = useCurrentOrPrev(chatAndContactIds, true)!; @@ -120,6 +131,7 @@ export default memo(withGlobal( orderedPinnedIds, }, currentUserId, + switchBotInline, } = global; return { @@ -129,6 +141,7 @@ export default memo(withGlobal( pinnedIds: orderedPinnedIds.active, contactIds: global.contactList?.userIds, currentUserId, + switchBotInline, }; }, )(ForwardPicker)); diff --git a/src/components/main/GameModal.scss b/src/components/main/GameModal.scss new file mode 100644 index 000000000..c0fcb7fcc --- /dev/null +++ b/src/components/main/GameModal.scss @@ -0,0 +1,48 @@ +.GameModal { + .modal-dialog { + max-width: 80%; + height: 100%; + justify-content: center; + background-color: transparent; + border: none; + box-shadow: none; + margin: 0; + } + + .modal-header { + display: none; + } + + .modal-content { + overflow: hidden; + } + + .game-frame { + width: 100%; + height: 100%; + border: 0; + border-radius: var(--border-radius-default); + } + + @media (max-width: 600px) { + .modal-dialog { + background-color: var(--color-background); + max-width: 100% !important; + border-radius: 0; + } + + .modal-header { + display: flex; + padding: 0.5rem; + } + + .modal-content { + max-height: none; + padding: 0; + } + + .game-frame { + border-radius: 0; + } + } +} diff --git a/src/components/main/GameModal.tsx b/src/components/main/GameModal.tsx new file mode 100644 index 000000000..c4405ca50 --- /dev/null +++ b/src/components/main/GameModal.tsx @@ -0,0 +1,95 @@ +import React, { + FC, memo, useCallback, useEffect, +} from '../../lib/teact/teact'; +import { getActions } from '../../lib/teact/teactn'; +import { GlobalState } from '../../global/types'; + +import windowSize from '../../util/windowSize'; + +import useLang from '../../hooks/useLang'; +import useSendMessageAction from '../../hooks/useSendMessageAction'; +import useInterval from '../../hooks/useInterval'; + +import Modal from '../ui/Modal'; + +import './GameModal.scss'; + +type GameEvents = { eventType: 'share_score' | 'share_game' }; + +const PLAY_GAME_ACTION_INTERVAL = 5000; + +type OwnProps = { + openedGame?: GlobalState['openedGame']; + gameTitle?: string; +}; + +const GameModal: FC = ({ openedGame, gameTitle }) => { + const { closeGame, showNotification, openForwardMenu } = getActions(); + const lang = useLang(); + const { url, chatId, messageId } = openedGame || {}; + const isOpen = Boolean(url); + + const sendMessageAction = useSendMessageAction(chatId); + useInterval(() => { + sendMessageAction({ type: 'playingGame' }); + }, isOpen ? PLAY_GAME_ACTION_INTERVAL : undefined); + + const handleMessage = useCallback((event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as GameEvents; + if (data.eventType === 'share_score') { + openForwardMenu({ fromChatId: chatId, messageIds: [messageId], withMyScore: true }); + closeGame(); + } + + if (data.eventType === 'share_game') { + showNotification({ message: 'Unsupported game action' }); + } + } catch (e) { + // Ignore messages from other origins + } + }, [chatId, closeGame, messageId, openForwardMenu, showNotification]); + + const handleLoad = useCallback((event: React.SyntheticEvent) => { + event.currentTarget.focus(); + }, []); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [handleMessage]); + + // Prevent refresh when rotating device + useEffect(() => { + if (!isOpen) return undefined; + + windowSize.disableRefresh(); + + return () => { + windowSize.enableRefresh(); + }; + }, [isOpen]); + + return ( + + {isOpen && ( +