Implement Games (#1797)

This commit is contained in:
Alexander Zinchuk 2022-04-08 20:59:24 +02:00
parent cd4e9077d5
commit d7226fded9
37 changed files with 914 additions and 105 deletions

View File

@ -29,6 +29,7 @@ import {
ApiSponsoredMessage, ApiSponsoredMessage,
ApiUser, ApiUser,
ApiLocation, ApiLocation,
ApiGame,
} from '../../types'; } from '../../types';
import { import {
@ -327,6 +328,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): ApiMes
const location = buildLocationFromMedia(media); const location = buildLocationFromMedia(media);
if (location) return { location }; if (location) return { location };
const game = buildGameFromMedia(media);
if (game) return { game };
return undefined; return undefined;
} }
@ -532,6 +536,16 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
} }
} else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) { } else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) {
mediaType = 'video'; 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 { export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll {
const { id, answers: rawAnswers } = poll; const { id, answers: rawAnswers } = poll;
const answers = rawAnswers.map((answer) => ({ const answers = rawAnswers.map((answer) => ({
@ -751,6 +792,7 @@ function buildAction(
const translationValues = []; const translationValues = [];
let type: ApiAction['type'] = 'other'; let type: ApiAction['type'] = 'other';
let photo: ApiPhoto | undefined; let photo: ApiPhoto | undefined;
let score: number | undefined;
const targetUserIds = 'users' in action const targetUserIds = 'users' in action
? action.users && action.users.map((id) => buildApiPeerId(id, 'user')) ? action.users && action.users.map((id) => buildApiPeerId(id, 'user'))
@ -868,6 +910,10 @@ function buildAction(
} else if (action instanceof GramJs.MessageActionChatJoinedByRequest) { } else if (action instanceof GramJs.MessageActionChatJoinedByRequest) {
text = 'ChatService.UserJoinedGroupByRequest'; text = 'ChatService.UserJoinedGroupByRequest';
translationValues.push('%action_origin%'); translationValues.push('%action_origin%');
} else if (action instanceof GramJs.MessageActionGameScore) {
text = senderId === currentUserId ? 'ActionYouScoredInGame' : 'ActionUserScoredInGame';
translationValues.push('%score%');
score = action.score;
} else { } else {
text = 'ChatList.UnsupportedMessage'; text = 'ChatList.UnsupportedMessage';
} }
@ -887,21 +933,22 @@ function buildAction(
currency, currency,
translationValues, translationValues,
call, call,
score,
}; };
} }
function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefined { 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 (!replyMarkup) {
if (media instanceof GramJs.MessageMediaWebPage && media.webpage instanceof GramJs.WebPage) { if (media instanceof GramJs.MessageMediaWebPage && media.webpage instanceof GramJs.WebPage) {
if (media.webpage.type === 'telegram_message') { if (media.webpage.type === 'telegram_message') {
return { return {
inlineButtons: [[{ inlineButtons: [[{
type: 'url' as const, type: 'url',
text: 'Show Message', text: 'Show Message',
messageId, url: media.webpage.url,
value: media.webpage.url,
}]], }]],
}; };
} }
@ -916,40 +963,103 @@ function buildReplyButtons(message: UniversalMessage): ApiReplyKeyboard | undefi
} }
const markup = replyMarkup.rows.map(({ buttons }) => { const markup = replyMarkup.rows.map(({ buttons }) => {
return buttons.map((button) => { return buttons.map((button): ApiKeyboardButton => {
let { text } = button; const { text } = button;
let type;
let value;
if (button instanceof GramJs.KeyboardButton) { if (button instanceof GramJs.KeyboardButton) {
type = 'command'; return {
value = text; type: 'command',
} else if (button instanceof GramJs.KeyboardButtonUrl) { text,
type = 'url'; };
value = button.url; }
} else if (button instanceof GramJs.KeyboardButtonCallback) {
type = 'callback'; if (button instanceof GramJs.KeyboardButtonUrl) {
value = serializeBytes(button.data); if (button.url.includes('?startgroup=')) {
} else if (button instanceof GramJs.KeyboardButtonRequestPoll) { return {
type = 'requestPoll'; type: 'unsupported',
} else if (button instanceof GramJs.KeyboardButtonRequestPhone) { text,
type = 'requestSelfContact'; };
} else if (button instanceof GramJs.KeyboardButtonBuy) {
if (media instanceof GramJs.MessageMediaInvoice && media.receiptMsgId) {
text = 'PaymentReceipt';
value = media.receiptMsgId;
} }
type = 'buy';
} else { return {
type = 'NOT_SUPPORTED'; 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 { return {
type, type: 'unsupported',
text, text,
messageId, };
value,
} as ApiKeyboardButton;
}); });
}); });

View File

@ -319,6 +319,9 @@ export function isMessageWithMedia(message: GramJs.Message | GramJs.UpdateServic
&& media.webpage.document.mimeType.startsWith('video') && 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(); return new GramJs.SendMessageRecordAudioAction();
case 'chooseSticker': case 'chooseSticker':
return new GramJs.SendMessageChooseStickerAction(); return new GramJs.SendMessageChooseStickerAction();
case 'playingGame':
return new GramJs.SendMessageGamePlayAction();
} }
return undefined; return undefined;
} }

View File

@ -31,6 +31,15 @@ export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageServ
localDb.documents[String(message.media.webpage.document.id)] = message.media.webpage.document; 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) { if (message instanceof GramJs.MessageService && 'photo' in message.action) {
addPhotoToLocalDb(message.action.photo); addPhotoToLocalDb(message.action.photo);
} }

View File

@ -16,14 +16,15 @@ export function init() {
} }
export async function answerCallbackButton({ 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({ const result = await invokeRequest(new GramJs.messages.GetBotCallbackAnswer({
peer: buildInputPeer(chatId, accessHash), peer: buildInputPeer(chatId, accessHash),
msgId: messageId, msgId: messageId,
data: deserializeBytes(data), data: data ? deserializeBytes(data) : undefined,
game: isGame || undefined,
})); }));
return result ? omitVirtualClassFields(result) : undefined; return result ? omitVirtualClassFields(result) : undefined;

View File

@ -1102,6 +1102,7 @@ export async function forwardMessages({
isSilent, isSilent,
scheduledAt, scheduledAt,
sendAs, sendAs,
withMyScore,
}: { }: {
fromChat: ApiChat; fromChat: ApiChat;
toChat: ApiChat; toChat: ApiChat;
@ -1110,6 +1111,7 @@ export async function forwardMessages({
isSilent?: boolean; isSilent?: boolean;
scheduledAt?: number; scheduledAt?: number;
sendAs?: ApiUser | ApiChat; sendAs?: ApiUser | ApiChat;
withMyScore?: boolean;
}) { }) {
const messageIds = messages.map(({ id }) => id); const messageIds = messages.map(({ id }) => id);
const randomIds = messages.map(generateRandomBigInt); const randomIds = messages.map(generateRandomBigInt);
@ -1131,7 +1133,8 @@ export async function forwardMessages({
toPeer: buildInputPeer(toChat.id, toChat.accessHash), toPeer: buildInputPeer(toChat.id, toChat.accessHash),
randomId: randomIds, randomId: randomIds,
id: messageIds, id: messageIds,
...(isSilent && { sil2ent: isSilent }), withMyScore: withMyScore || undefined,
silent: isSilent || undefined,
...(scheduledAt && { scheduleDate: scheduledAt }), ...(scheduledAt && { scheduleDate: scheduledAt }),
...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }),
}), true); }), true);

View File

@ -174,6 +174,16 @@ interface ApiGeoLive {
export type ApiLocation = ApiGeo | ApiVenue | 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 = { export type ApiNewPoll = {
summary: ApiPoll['summary']; summary: ApiPoll['summary'];
quiz?: { quiz?: {
@ -193,6 +203,7 @@ export interface ApiAction {
currency?: string; currency?: string;
translationValues: string[]; translationValues: string[];
call?: Partial<ApiGroupCall>; call?: Partial<ApiGroupCall>;
score?: number;
} }
export interface ApiWebPage { export interface ApiWebPage {
@ -272,6 +283,7 @@ export interface ApiMessage {
voice?: ApiVoice; voice?: ApiVoice;
invoice?: ApiInvoice; invoice?: ApiInvoice;
location?: ApiLocation; location?: ApiLocation;
game?: ApiGame;
}; };
date: number; date: number;
isOutgoing: boolean; isOutgoing: boolean;
@ -363,13 +375,60 @@ export type ApiSponsoredMessage = {
expiresAt: number; expiresAt: number;
}; };
export interface ApiKeyboardButton { // KeyboardButtons
type: 'command' | 'url' | 'callback' | 'requestPoll' | 'requestSelfContact' | 'buy' | 'NOT_SUPPORTED';
interface ApiKeyboardButtonSimple {
type: 'unsupported' | 'buy' | 'command' | 'requestPhone' | 'game';
text: string; 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 ApiKeyboardButtons = ApiKeyboardButton[][];
export type ApiReplyKeyboard = { export type ApiReplyKeyboard = {
keyboardPlaceholder?: string; keyboardPlaceholder?: string;
@ -385,7 +444,7 @@ export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse'
| 'copyright' | 'geoIrrelevant' | 'fake' | 'illegalDrugs' | 'personalDetails' | 'other'; | 'copyright' | 'geoIrrelevant' | 'fake' | 'illegalDrugs' | 'personalDetails' | 'other';
export type ApiSendMessageAction = { export type ApiSendMessageAction = {
type: 'cancel' | 'typing' | 'recordAudio' | 'chooseSticker'; type: 'cancel' | 'typing' | 'recordAudio' | 'chooseSticker' | 'playingGame';
}; };
export const MAIN_THREAD_ID = -1; export const MAIN_THREAD_ID = -1;

View File

@ -42,15 +42,17 @@ export function renderActionMessageText(
} }
const { const {
text, translationValues, amount, currency, call, text, translationValues, amount, currency, call, score,
} = message.content.action; } = message.content.action;
const content: TextPart[] = []; const content: TextPart[] = [];
const noLinks = options.asPlainText || options.asTextWithSpoilers; const noLinks = options.asPlainText || options.asTextWithSpoilers;
const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage const translationKey = text === 'Chat.Service.Group.UpdatedPinnedMessage1' && !targetMessage
? 'Message.PinnedGenericMessage' ? 'Message.PinnedGenericMessage'
: text; : text;
let unprocessed = lang(translationKey, translationValues?.length ? translationValues : undefined); 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[]; let processed: TextPart[];
if (unprocessed.includes('%payment_amount%')) { if (unprocessed.includes('%payment_amount%')) {
@ -76,6 +78,16 @@ export function renderActionMessageText(
unprocessed = processed.pop() as string; unprocessed = processed.pop() as string;
content.push(...processed); content.push(...processed);
if (unprocessed.includes('%score%')) {
processed = processPlaceholder(
unprocessed,
'%score%',
score!.toString(),
);
unprocessed = processed.pop() as string;
content.push(...processed);
}
processed = processPlaceholder( processed = processPlaceholder(
unprocessed, unprocessed,
'%target_user%', '%target_user%',

View File

@ -4,6 +4,7 @@ import React, {
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, getGlobal, withGlobal } from '../../global';
import { ApiChat, MAIN_THREAD_ID } from '../../api/types'; import { ApiChat, MAIN_THREAD_ID } from '../../api/types';
import { GlobalState } from '../../global/types';
import { import {
filterChatsByName, filterChatsByName,
@ -29,6 +30,7 @@ type StateProps = {
pinnedIds?: string[]; pinnedIds?: string[];
contactIds?: string[]; contactIds?: string[];
currentUserId?: string; currentUserId?: string;
switchBotInline?: GlobalState['switchBotInline'];
}; };
const ForwardPicker: FC<OwnProps & StateProps> = ({ const ForwardPicker: FC<OwnProps & StateProps> = ({
@ -39,10 +41,13 @@ const ForwardPicker: FC<OwnProps & StateProps> = ({
contactIds, contactIds,
currentUserId, currentUserId,
isOpen, isOpen,
switchBotInline,
}) => { }) => {
const { const {
setForwardChatId, setForwardChatId,
exitForwardMode, exitForwardMode,
openChatWithText,
resetSwitchBotInline,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -86,8 +91,14 @@ const ForwardPicker: FC<OwnProps & StateProps> = ({
}, [activeListIds, archivedListIds, chatsById, contactIds, currentUserId, filter, isOpen, lang, pinnedIds]); }, [activeListIds, archivedListIds, chatsById, contactIds, currentUserId, filter, isOpen, lang, pinnedIds]);
const handleSelectUser = useCallback((userId: string) => { const handleSelectUser = useCallback((userId: string) => {
setForwardChatId({ id: userId }); if (switchBotInline) {
}, [setForwardChatId]); const text = `@${switchBotInline.botUsername} ${switchBotInline.query}`;
openChatWithText({ chatId: userId, text });
resetSwitchBotInline();
} else {
setForwardChatId({ id: userId });
}
}, [openChatWithText, resetSwitchBotInline, setForwardChatId, switchBotInline]);
const renderingChatAndContactIds = useCurrentOrPrev(chatAndContactIds, true)!; const renderingChatAndContactIds = useCurrentOrPrev(chatAndContactIds, true)!;
@ -120,6 +131,7 @@ export default memo(withGlobal<OwnProps>(
orderedPinnedIds, orderedPinnedIds,
}, },
currentUserId, currentUserId,
switchBotInline,
} = global; } = global;
return { return {
@ -129,6 +141,7 @@ export default memo(withGlobal<OwnProps>(
pinnedIds: orderedPinnedIds.active, pinnedIds: orderedPinnedIds.active,
contactIds: global.contactList?.userIds, contactIds: global.contactList?.userIds,
currentUserId, currentUserId,
switchBotInline,
}; };
}, },
)(ForwardPicker)); )(ForwardPicker));

View File

@ -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;
}
}
}

View File

@ -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<OwnProps> = ({ 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<string>) => {
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<HTMLIFrameElement>) => {
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 (
<Modal
className="GameModal"
isOpen={isOpen}
onClose={closeGame}
title={gameTitle}
hasCloseButton
>
{isOpen && (
<iframe
className="game-frame"
onLoad={handleLoad}
src={url}
title={lang('AttachGame')}
sandbox="allow-scripts allow-same-origin allow-orientation-lock"
allow="fullscreen"
/>
)}
</Modal>
);
};
export default memo(GameModal);

View File

@ -5,6 +5,7 @@ import { getActions, withGlobal } from '../../global';
import { LangCode } from '../../types'; import { LangCode } from '../../types';
import { ApiMessage, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType } from '../../api/types'; import { ApiMessage, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType } from '../../api/types';
import { GlobalState } from '../../global/types';
import '../../global/actions/all'; import '../../global/actions/all';
import { import {
@ -40,6 +41,7 @@ import RightColumn from '../right/RightColumn';
import MediaViewer from '../mediaViewer/MediaViewer.async'; import MediaViewer from '../mediaViewer/MediaViewer.async';
import AudioPlayer from '../middle/AudioPlayer'; import AudioPlayer from '../middle/AudioPlayer';
import DownloadManager from './DownloadManager'; import DownloadManager from './DownloadManager';
import GameModal from './GameModal';
import Notifications from './Notifications.async'; import Notifications from './Notifications.async';
import Dialogs from './Dialogs.async'; import Dialogs from './Dialogs.async';
import ForwardPicker from './ForwardPicker.async'; import ForwardPicker from './ForwardPicker.async';
@ -76,6 +78,8 @@ type StateProps = {
addedSetIds?: string[]; addedSetIds?: string[];
newContactUserId?: string; newContactUserId?: string;
newContactByPhoneNumber?: boolean; newContactByPhoneNumber?: boolean;
openedGame?: GlobalState['openedGame'];
gameTitle?: string;
}; };
const NOTIFICATION_INTERVAL = 1000; const NOTIFICATION_INTERVAL = 1000;
@ -109,6 +113,8 @@ const Main: FC<StateProps> = ({
addedSetIds, addedSetIds,
newContactUserId, newContactUserId,
newContactByPhoneNumber, newContactByPhoneNumber,
openedGame,
gameTitle,
}) => { }) => {
const { const {
sync, sync,
@ -340,6 +346,7 @@ const Main: FC<StateProps> = ({
userId={newContactUserId} userId={newContactUserId}
isByPhoneNumber={newContactByPhoneNumber} isByPhoneNumber={newContactByPhoneNumber}
/> />
<GameModal openedGame={openedGame} gameTitle={gameTitle} />
<DownloadManager /> <DownloadManager />
<CallFallbackConfirm isOpen={isCallFallbackConfirmOpen} /> <CallFallbackConfirm isOpen={isCallFallbackConfirmOpen} />
<UnreadCount isForAppBadge /> <UnreadCount isForAppBadge />
@ -375,6 +382,9 @@ export default memo(withGlobal(
const audioMessage = audioChatId && audioMessageId const audioMessage = audioChatId && audioMessageId
? selectChatMessage(global, audioChatId, audioMessageId) ? selectChatMessage(global, audioChatId, audioMessageId)
: undefined; : undefined;
const openedGame = global.openedGame;
const gameMessage = openedGame && selectChatMessage(global, openedGame.chatId, openedGame.messageId);
const gameTitle = gameMessage?.content.game?.title;
return { return {
connectionState: global.connectionState, connectionState: global.connectionState,
@ -400,6 +410,8 @@ export default memo(withGlobal(
addedSetIds: global.stickers.added.setIds, addedSetIds: global.stickers.added.setIds,
newContactUserId: global.newContact?.userId, newContactUserId: global.newContact?.userId,
newContactByPhoneNumber: global.newContact?.isByPhoneNumber, newContactByPhoneNumber: global.newContact?.isByPhoneNumber,
openedGame,
gameTitle,
}; };
}, },
)(Main)); )(Main));

View File

@ -28,7 +28,7 @@ type StateProps = {
const BotKeyboardMenu: FC<OwnProps & StateProps> = ({ const BotKeyboardMenu: FC<OwnProps & StateProps> = ({
isOpen, message, onClose, isOpen, message, onClose,
}) => { }) => {
const { clickInlineButton } = getActions(); const { clickBotInlineButton } = getActions();
const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose); const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose);
const { isKeyboardSingleUse } = message || {}; const { isKeyboardSingleUse } = message || {};
@ -66,9 +66,9 @@ const BotKeyboardMenu: FC<OwnProps & StateProps> = ({
{row.map((button) => ( {row.map((button) => (
<Button <Button
ripple ripple
disabled={button.type === 'NOT_SUPPORTED'} disabled={button.type === 'unsupported'}
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
onClick={() => clickInlineButton({ button })} onClick={() => clickBotInlineButton({ messageId: message.id, button })}
> >
{button.text} {button.text}
</Button> </Button>

View File

@ -42,6 +42,7 @@ import {
selectCanScheduleUntilOnline, selectCanScheduleUntilOnline,
selectEditingScheduledDraft, selectEditingScheduledDraft,
selectEditingDraft, selectEditingDraft,
selectRequestedText,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { import {
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
@ -127,7 +128,7 @@ type StateProps =
isRightColumnShown?: boolean; isRightColumnShown?: boolean;
isSelectModeActive?: boolean; isSelectModeActive?: boolean;
isForwarding?: boolean; isForwarding?: boolean;
isPollModalOpen?: boolean; pollModal: GlobalState['pollModal'];
botKeyboardMessageId?: number; botKeyboardMessageId?: number;
botKeyboardPlaceholder?: string; botKeyboardPlaceholder?: string;
withScheduledButton?: boolean; withScheduledButton?: boolean;
@ -151,6 +152,7 @@ type StateProps =
sendAsChat?: ApiChat; sendAsChat?: ApiChat;
sendAsId?: string; sendAsId?: string;
editingDraft?: ApiFormattedText; editingDraft?: ApiFormattedText;
requestedText?: string;
} }
& Pick<GlobalState, 'connectionState'>; & Pick<GlobalState, 'connectionState'>;
@ -196,7 +198,7 @@ const Composer: FC<OwnProps & StateProps> = ({
isRightColumnShown, isRightColumnShown,
isSelectModeActive, isSelectModeActive,
isForwarding, isForwarding,
isPollModalOpen, pollModal,
botKeyboardMessageId, botKeyboardMessageId,
botKeyboardPlaceholder, botKeyboardPlaceholder,
withScheduledButton, withScheduledButton,
@ -218,6 +220,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendAsChat, sendAsChat,
sendAsId, sendAsId,
editingDraft, editingDraft,
requestedText,
}) => { }) => {
const { const {
sendMessage, sendMessage,
@ -234,6 +237,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendInlineBotResult, sendInlineBotResult,
loadSendAs, loadSendAs,
loadFullChat, loadFullChat,
resetOpenChatWithText,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -657,6 +661,17 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
}, [contentToBeScheduled, handleMessageSchedule, requestCalendar]); }, [contentToBeScheduled, handleMessageSchedule, requestCalendar]);
useEffect(() => {
if (requestedText) {
setHtml(requestedText);
resetOpenChatWithText();
requestAnimationFrame(() => {
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
focusEditableElement(messageInput, true);
});
}
}, [requestedText, resetOpenChatWithText]);
const handleStickerSelect = useCallback(( const handleStickerSelect = useCallback((
sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false, sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false,
) => { ) => {
@ -945,7 +960,8 @@ const Composer: FC<OwnProps & StateProps> = ({
onClear={handleClearAttachment} onClear={handleClearAttachment}
/> />
<PollModal <PollModal
isOpen={Boolean(isPollModalOpen)} isOpen={pollModal.isOpen}
isQuiz={pollModal.isQuiz}
shouldBeAnonimous={isChannel} shouldBeAnonimous={isChannel}
onClear={closePollModal} onClear={closePollModal}
onSend={handlePollSend} onSend={handlePollSend}
@ -1213,6 +1229,7 @@ export default memo(withGlobal<OwnProps>(
: (chat?.adminRights?.anonymous ? chat?.id : undefined); : (chat?.adminRights?.anonymous ? chat?.id : undefined);
const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined;
const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined;
const requestedText = selectRequestedText(global, chatId);
const editingDraft = messageListType === 'scheduled' const editingDraft = messageListType === 'scheduled'
? selectEditingScheduledDraft(global, chatId) ? selectEditingScheduledDraft(global, chatId)
@ -1238,7 +1255,7 @@ export default memo(withGlobal<OwnProps>(
botKeyboardMessageId, botKeyboardMessageId,
botKeyboardPlaceholder: keyboardMessage?.keyboardPlaceholder, botKeyboardPlaceholder: keyboardMessage?.keyboardPlaceholder,
isForwarding: chatId === global.forwardMessages.toChatId, isForwarding: chatId === global.forwardMessages.toChatId,
isPollModalOpen: global.isPollModalOpen, pollModal: global.pollModal,
stickersForEmoji: global.stickers.forEmoji.stickers, stickersForEmoji: global.stickers.forEmoji.stickers,
groupChatMembers: chat?.fullInfo?.members, groupChatMembers: chat?.fullInfo?.members,
topInlineBotIds: global.topInlineBots?.userIds, topInlineBotIds: global.topInlineBots?.userIds,
@ -1257,6 +1274,7 @@ export default memo(withGlobal<OwnProps>(
sendAsChat, sendAsChat,
sendAsId, sendAsId,
editingDraft, editingDraft,
requestedText,
}; };
}, },
)(Composer)); )(Composer));

View File

@ -425,7 +425,7 @@ export default memo(withGlobal<OwnProps>(
return { return {
messageSendKeyCombo, messageSendKeyCombo,
replyingToId: chatId && threadId ? selectReplyingToId(global, chatId, threadId) : undefined, replyingToId: chatId && threadId ? selectReplyingToId(global, chatId, threadId) : undefined,
noTabCapture: global.isPollModalOpen || global.payment.isPaymentModalOpen, noTabCapture: global.pollModal.isOpen || global.payment.isPaymentModalOpen,
}; };
}, },
)(MessageInput)); )(MessageInput));

View File

@ -20,6 +20,7 @@ import './PollModal.scss';
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
shouldBeAnonimous?: boolean; shouldBeAnonimous?: boolean;
isQuiz?: boolean;
onSend: (pollSummary: ApiNewPoll) => void; onSend: (pollSummary: ApiNewPoll) => void;
onClear: () => void; onClear: () => void;
}; };
@ -31,7 +32,7 @@ const MAX_QUESTION_LENGTH = 255;
const MAX_SOLUTION_LENGTH = 200; const MAX_SOLUTION_LENGTH = 200;
const PollModal: FC<OwnProps> = ({ const PollModal: FC<OwnProps> = ({
isOpen, shouldBeAnonimous, onSend, onClear, isOpen, isQuiz, shouldBeAnonimous, onSend, onClear,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const questionInputRef = useRef<HTMLInputElement>(null); const questionInputRef = useRef<HTMLInputElement>(null);
@ -44,8 +45,8 @@ const PollModal: FC<OwnProps> = ({
const [options, setOptions] = useState<string[]>(['']); const [options, setOptions] = useState<string[]>(['']);
const [isAnonymous, setIsAnonymous] = useState(true); const [isAnonymous, setIsAnonymous] = useState(true);
const [isMultipleAnswers, setIsMultipleAnswers] = useState(false); const [isMultipleAnswers, setIsMultipleAnswers] = useState(false);
const [isQuizMode, setIsQuizMode] = useState(false); const [isQuizMode, setIsQuizMode] = useState(isQuiz || false);
const [solution, setSolution] = useState<string>(); const [solution, setSolution] = useState<string>('');
const [correctOption, setCorrectOption] = useState<string>(); const [correctOption, setCorrectOption] = useState<string>();
const [hasErrors, setHasErrors] = useState<boolean>(false); const [hasErrors, setHasErrors] = useState<boolean>(false);
@ -64,12 +65,12 @@ const PollModal: FC<OwnProps> = ({
setOptions(['']); setOptions(['']);
setIsAnonymous(true); setIsAnonymous(true);
setIsMultipleAnswers(false); setIsMultipleAnswers(false);
setIsQuizMode(false); setIsQuizMode(isQuiz || false);
setSolution(''); setSolution('');
setCorrectOption(''); setCorrectOption('');
setHasErrors(false); setHasErrors(false);
} }
}, [isOpen]); }, [isQuiz, isOpen]);
useEffect(() => focusInput(questionInputRef), [focusInput, isOpen]); useEffect(() => focusInput(questionInputRef), [focusInput, isOpen]);
@ -338,7 +339,7 @@ const PollModal: FC<OwnProps> = ({
<Checkbox <Checkbox
label={lang('PollQuiz')} label={lang('PollQuiz')}
checked={isQuizMode} checked={isQuizMode}
disabled={isMultipleAnswers} disabled={isMultipleAnswers || isQuiz !== undefined}
onChange={handleQuizModeChange} onChange={handleQuizModeChange}
/> />
{isQuizMode && ( {isQuizMode && (

View File

@ -0,0 +1,34 @@
.Game {
.title {
font-weight: 500;
}
.description {
line-height: 1.2;
margin-bottom: 0.75rem;
}
.preview {
position: relative;
max-width: 100%;
margin-bottom: 0.25rem;
margin-top: 0.25rem;
cursor: pointer;
}
.preview-content {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.5rem;
overflow: hidden;
}
.skeleton {
aspect-ratio: 16/9;
}
}

View File

@ -0,0 +1,84 @@
import React, {
FC, memo,
} from '../../../lib/teact/teact';
import { ApiMessage } from '../../../api/types';
import { getActions } from '../../../global';
import { getGamePreviewPhotoHash, getGamePreviewVideoHash, getMessageText } from '../../../global/helpers';
import useMedia from '../../../hooks/useMedia';
import Skeleton from '../../ui/Skeleton';
import './Game.scss';
const DEFAULT_PREVIEW_DIMENSIONS = {
width: 480,
height: 270,
};
type OwnProps = {
message: ApiMessage;
canAutoLoadMedia?: boolean;
lastSyncTime?: number;
};
const Game: FC<OwnProps> = ({
message,
canAutoLoadMedia,
lastSyncTime,
}) => {
const { clickBotInlineButton } = getActions();
const game = message.content.game!;
const {
title, description,
} = game;
const photoHash = Boolean(lastSyncTime) && getGamePreviewPhotoHash(game);
const videoHash = Boolean(lastSyncTime) && getGamePreviewVideoHash(game);
const photoBlobUrl = useMedia(photoHash, !canAutoLoadMedia);
const videoBlobUrl = useMedia(videoHash, !canAutoLoadMedia);
const handleGameClick = () => {
clickBotInlineButton({
messageId: message.id,
button: message.inlineButtons![0][0],
});
};
return (
<div className="Game">
<div
className="preview"
style={`width: ${DEFAULT_PREVIEW_DIMENSIONS.width}px; height: ${DEFAULT_PREVIEW_DIMENSIONS.height}px`}
onClick={handleGameClick}
>
{!photoBlobUrl && !videoBlobUrl && (
<Skeleton className="skeleton preview-content" />
)}
{photoBlobUrl && (
<img
className="preview-content"
src={photoBlobUrl}
alt={title}
/>
)}
{videoBlobUrl && (
<video
className="preview-content"
playsInline
muted
autoPlay
loop
src={videoBlobUrl}
/>
)}
</div>
<div className="title">{title}</div>
{!getMessageText(message) && <div className="description">{description}</div>}
</div>
);
};
export default memo(Game);

View File

@ -12,12 +12,11 @@ import './InlineButtons.scss';
type OwnProps = { type OwnProps = {
message: ApiMessage; message: ApiMessage;
onClick: ({ button }: { button: ApiKeyboardButton }) => void; onClick: ({ messageId, button }: { messageId: number; button: ApiKeyboardButton }) => void;
}; };
const InlineButtons: FC<OwnProps> = ({ message, onClick }) => { const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
const lang = useLang(); const lang = useLang();
return ( return (
<div className="InlineButtons"> <div className="InlineButtons">
{message.inlineButtons!.map((row) => ( {message.inlineButtons!.map((row) => (
@ -26,13 +25,14 @@ const InlineButtons: FC<OwnProps> = ({ message, onClick }) => {
<Button <Button
size="tiny" size="tiny"
ripple ripple
disabled={button.type === 'NOT_SUPPORTED'} disabled={button.type === 'unsupported'}
// eslint-disable-next-line react/jsx-no-bind // eslint-disable-next-line react/jsx-no-bind
onClick={() => onClick({ button })} onClick={() => onClick({ messageId: message.id, button })}
> >
{renderText(lang(button.text))} {renderText(lang(button.text))}
{button.type === 'buy' && <i className="icon-card" />} {['buy', 'receipt'].includes(button.type) && <i className="icon-card" />}
{button.type === 'url' && !button.value!.match(RE_TME_LINK) && <i className="icon-arrow-right" />} {button.type === 'url' && !RE_TME_LINK.test(button.url) && <i className="icon-arrow-right" />}
{button.type === 'switchBotInline' && <i className="icon-share-filled" />}
</Button> </Button>
))} ))}
</div> </div>

View File

@ -106,6 +106,7 @@ import Poll from './Poll';
import WebPage from './WebPage'; import WebPage from './WebPage';
import Invoice from './Invoice'; import Invoice from './Invoice';
import Location from './Location'; import Location from './Location';
import Game from './Game';
import Album from './Album'; import Album from './Album';
import RoundVideo from './RoundVideo'; import RoundVideo from './RoundVideo';
import InlineButtons from './InlineButtons'; import InlineButtons from './InlineButtons';
@ -281,7 +282,7 @@ const Message: FC<OwnProps & StateProps> = ({
}) => { }) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
clickInlineButton, clickBotInlineButton,
disableContextMenuHint, disableContextMenuHint,
} = getActions(); } = getActions();
@ -461,7 +462,7 @@ const Message: FC<OwnProps & StateProps> = ({
); );
const { const {
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, game,
} = getMessageContent(message); } = getMessageContent(message);
const contentClassName = buildContentClassName(message, { const contentClassName = buildContentClassName(message, {
@ -753,6 +754,13 @@ const Message: FC<OwnProps & StateProps> = ({
{poll && ( {poll && (
<Poll message={message} poll={poll} onSendVote={handleVoteSend} /> <Poll message={message} poll={poll} onSendVote={handleVoteSend} />
)} )}
{game && (
<Game
message={message}
canAutoLoadMedia={canAutoLoadMedia}
lastSyncTime={lastSyncTime}
/>
)}
{!hasAnimatedEmoji && textParts && ( {!hasAnimatedEmoji && textParts && (
<p className={textContentClass} dir="auto"> <p className={textContentClass} dir="auto">
{textParts} {textParts}
@ -935,7 +943,7 @@ const Message: FC<OwnProps & StateProps> = ({
)} )}
</div> </div>
{message.inlineButtons && ( {message.inlineButtons && (
<InlineButtons message={message} onClick={clickInlineButton} /> <InlineButtons message={message} onClick={clickBotInlineButton} />
)} )}
{reactionsPosition === 'outside' && ( {reactionsPosition === 'outside' && (
<Reactions <Reactions

View File

@ -1,6 +1,9 @@
.Skeleton { .Skeleton {
position: relative; position: relative;
background-color: var(--color-skeleton-background); background-color: var(--color-skeleton-background);
width: 100%;
height: 100%;
&.round { &.round {
border-radius: 50%; border-radius: 50%;
} }

View File

@ -13,7 +13,11 @@ type OwnProps = {
}; };
const Skeleton: FC<OwnProps> = ({ const Skeleton: FC<OwnProps> = ({
variant = 'rectangular', animation = 'wave', width, height, className, variant = 'rectangular',
animation = 'wave',
width,
height,
className,
}) => { }) => {
const classNames = buildClassName('Skeleton', variant, animation, className); const classNames = buildClassName('Skeleton', variant, animation, className);
const style = (width ? `width: ${width}px;` : '') + (height ? `height: ${height}px;` : ''); const style = (width ? `width: ${width}px;` : '') + (height ? `height: ${height}px;` : '');

View File

@ -18,37 +18,41 @@ import { buildCollectionByKey } from '../../../util/iteratees';
import { debounce } from '../../../util/schedulers'; import { debounce } from '../../../util/schedulers';
import { replaceInlineBotSettings, replaceInlineBotsIsLoading } from '../../reducers/bots'; import { replaceInlineBotSettings, replaceInlineBotsIsLoading } from '../../reducers/bots';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import PopupManager from '../../../util/PopupManager';
const GAMEE_URL = 'https://prizes.gamee.com/';
const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min
const runDebouncedForSearch = debounce((cb) => cb(), 500, false); const runDebouncedForSearch = debounce((cb) => cb(), 500, false);
addActionHandler('clickInlineButton', (global, actions, payload) => { addActionHandler('clickBotInlineButton', (global, actions, payload) => {
const { button } = payload; const { messageId, button } = payload;
switch (button.type) { switch (button.type) {
case 'command': case 'command':
actions.sendBotCommand({ command: button.value }); actions.sendBotCommand({ command: button.text });
break; break;
case 'url': case 'url': {
if (button.value.match(RE_TME_LINK) || button.value.match(RE_TG_LINK)) { const { url } = button;
actions.openTelegramLink({ url: button.value }); if (url.match(RE_TME_LINK) || url.match(RE_TG_LINK)) {
actions.openTelegramLink({ url });
} else { } else {
actions.toggleSafeLinkModal({ url: button.value }); actions.toggleSafeLinkModal({ url });
} }
break; break;
}
case 'callback': { case 'callback': {
const chat = selectCurrentChat(global); const chat = selectCurrentChat(global);
if (!chat) { if (!chat) {
return; return;
} }
void answerCallbackButton(chat, button.messageId, button.value); void answerCallbackButton(chat, messageId, button.data);
break; break;
} }
case 'requestPoll': case 'requestPoll':
actions.openPollModal(); actions.openPollModal({ isQuiz: button.isQuiz });
break; break;
case 'requestSelfContact': { case 'requestPhone': {
const user = global.currentUserId ? selectUser(global, global.currentUserId) : undefined; const user = global.currentUserId ? selectUser(global, global.currentUserId) : undefined;
if (!user) { if (!user) {
return; return;
@ -63,20 +67,44 @@ addActionHandler('clickInlineButton', (global, actions, payload) => {
}); });
break; break;
} }
case 'receipt': {
const chat = selectCurrentChat(global);
if (!chat) {
return;
}
const { receiptMessageId } = button;
actions.getReceipt({ receiptMessageId, chatId: chat.id, messageId });
break;
}
case 'buy': { case 'buy': {
const chat = selectCurrentChat(global); const chat = selectCurrentChat(global);
const { messageId, value } = button;
if (!chat) { if (!chat) {
return; return;
} }
if (value) { actions.getPaymentForm({ chat, messageId });
actions.getReceipt({ receiptMessageId: value, chatId: chat.id, messageId }); actions.setInvoiceMessageInfo(selectChatMessage(global, chat.id, messageId));
} else { actions.openPaymentModal({ chatId: chat.id, messageId });
actions.getPaymentForm({ chat, messageId }); break;
actions.setInvoiceMessageInfo(selectChatMessage(global, chat.id, messageId)); }
actions.openPaymentModal({ chatId: chat.id, messageId }); case 'game': {
const chat = selectCurrentChat(global);
if (!chat) {
return;
} }
void answerCallbackButton(chat, messageId, undefined, true);
break;
}
case 'switchBotInline': {
const { query, isSamePeer } = button;
actions.switchBotInline({ query, isSamePeer, messageId });
break;
}
case 'userProfile': {
const { userId } = button;
actions.openChatWithInfo({ id: userId });
break; break;
} }
} }
@ -193,6 +221,45 @@ addActionHandler('queryInlineBot', async (global, actions, payload) => {
}); });
}); });
addActionHandler('switchBotInline', (global, actions, payload) => {
const { query, isSamePeer, messageId } = payload;
const chat = selectCurrentChat(global);
if (!chat) {
return undefined;
}
const message = selectChatMessage(global, chat.id, messageId);
if (!message) {
return undefined;
}
const botSender = selectChatBot(global, message.senderId!);
if (!botSender) {
return undefined;
}
const text = `@${botSender.username} ${query}`;
if (isSamePeer) {
actions.openChatWithText({ chatId: chat.id, text });
return undefined;
}
return {
...global,
switchBotInline: {
query,
botUsername: botSender.username,
},
};
});
addActionHandler('resetSwitchBotInline', (global) => {
return {
...global,
switchBotInline: undefined,
};
});
addActionHandler('sendInlineBotResult', (global, actions, payload) => { addActionHandler('sendInlineBotResult', (global, actions, payload) => {
const { const {
id, queryId, isSilent, scheduledAt, id, queryId, isSilent, scheduledAt,
@ -327,19 +394,34 @@ async function sendBotCommand(
}); });
} }
async function answerCallbackButton(chat: ApiChat, messageId: number, data: string) { let gameePopups: PopupManager | undefined;
async function answerCallbackButton(chat: ApiChat, messageId: number, data?: string, isGame = false) {
const {
showDialog, showNotification, toggleSafeLinkModal, openGame,
} = getActions();
if (isGame) {
if (!gameePopups) {
gameePopups = new PopupManager('popup,width=800,height=600', () => {
showNotification({ message: 'Allow browser to open popup window' });
});
}
gameePopups.preOpenIfNeeded();
}
const result = await callApi('answerCallbackButton', { const result = await callApi('answerCallbackButton', {
chatId: chat.id, chatId: chat.id,
accessHash: chat.accessHash, accessHash: chat.accessHash,
messageId, messageId,
data, data,
isGame,
}); });
if (!result) { if (!result) {
return; return;
} }
const { showDialog, showNotification, toggleSafeLinkModal } = getActions();
const { message, alert: isError, url } = result; const { message, alert: isError, url } = result;
if (isError) { if (isError) {
@ -347,6 +429,16 @@ async function answerCallbackButton(chat: ApiChat, messageId: number, data: stri
} else if (message) { } else if (message) {
showNotification({ message }); showNotification({ message });
} else if (url) { } else if (url) {
toggleSafeLinkModal({ url }); if (isGame) {
// Workaround for Gamee embedding bug
if (url.includes(GAMEE_URL)) {
gameePopups!.open(url);
} else {
gameePopups!.cancelPreOpen();
openGame({ url, chatId: chat.id, messageId });
}
} else {
toggleSafeLinkModal({ url });
}
} }
} }

View File

@ -905,6 +905,30 @@ addActionHandler('setActiveChatFolder', (global, actions, payload) => {
}; };
}); });
addActionHandler('openChatWithText', (global, actions, payload) => {
const { chatId, text } = payload;
actions.openChat({ id: chatId });
actions.exitMessageSelectMode();
global = getGlobal();
return {
...global,
openChatWithText: {
chatId,
text,
},
};
});
addActionHandler('resetOpenChatWithText', (global) => {
return {
...global,
openChatWithText: undefined,
};
});
addActionHandler('loadMoreMembers', async (global) => { addActionHandler('loadMoreMembers', async (global) => {
const { chatId } = selectCurrentMessageList(global) || {}; const { chatId } = selectCurrentMessageList(global) || {};
const chat = chatId ? selectChat(global, chatId) : undefined; const chat = chatId ? selectChat(global, chatId) : undefined;

View File

@ -574,7 +574,9 @@ addActionHandler('loadPollOptionResults', (global, actions, payload) => {
}); });
addActionHandler('forwardMessages', (global, action, payload) => { addActionHandler('forwardMessages', (global, action, payload) => {
const { fromChatId, messageIds, toChatId } = global.forwardMessages; const {
fromChatId, messageIds, toChatId, withMyScore,
} = global.forwardMessages;
const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined; const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined;
const toChat = toChatId ? selectChat(global, toChatId) : undefined; const toChat = toChatId ? selectChat(global, toChatId) : undefined;
const messages = fromChatId && messageIds const messages = fromChatId && messageIds
@ -600,6 +602,7 @@ addActionHandler('forwardMessages', (global, action, payload) => {
isSilent, isSilent,
scheduledAt, scheduledAt,
sendAs, sendAs,
withMyScore,
}); });
} }

View File

@ -410,7 +410,9 @@ addActionHandler('focusMessage', (global, actions, payload) => {
}); });
addActionHandler('openForwardMenu', (global, actions, payload) => { addActionHandler('openForwardMenu', (global, actions, payload) => {
const { fromChatId, messageIds, groupedId } = payload!; const {
fromChatId, messageIds, groupedId, withMyScore,
} = payload!;
let groupedMessageIds; let groupedMessageIds;
if (groupedId) { if (groupedId) {
groupedMessageIds = selectMessageIdsByGroupId(global, fromChatId, groupedId); groupedMessageIds = selectMessageIdsByGroupId(global, fromChatId, groupedId);
@ -421,6 +423,7 @@ addActionHandler('openForwardMenu', (global, actions, payload) => {
fromChatId, fromChatId,
messageIds: groupedMessageIds || messageIds, messageIds: groupedMessageIds || messageIds,
isModalShown: true, isModalShown: true,
withMyScore,
}, },
}; };
}); });
@ -573,17 +576,23 @@ addActionHandler('disableContextMenuHint', (global) => {
addActionHandler('exitMessageSelectMode', exitMessageSelectMode); addActionHandler('exitMessageSelectMode', exitMessageSelectMode);
addActionHandler('openPollModal', (global) => { addActionHandler('openPollModal', (global, actions, payload) => {
const { isQuiz } = payload || {};
return { return {
...global, ...global,
isPollModalOpen: true, pollModal: {
isOpen: true,
isQuiz,
},
}; };
}); });
addActionHandler('closePollModal', (global) => { addActionHandler('closePollModal', (global) => {
return { return {
...global, ...global,
isPollModalOpen: false, pollModal: {
isOpen: false,
},
}; };
}); });

View File

@ -261,3 +261,22 @@ addActionHandler('closeHistoryCalendar', (global) => {
historyCalendarSelectedAt: undefined, historyCalendarSelectedAt: undefined,
}; };
}); });
addActionHandler('openGame', (global, actions, payload) => {
const { url, chatId, messageId } = payload;
return {
...global,
openedGame: {
url,
chatId,
messageId,
},
};
});
addActionHandler('closeGame', (global) => {
return {
...global,
openedGame: undefined,
};
});

View File

@ -219,6 +219,12 @@ function migrateCache(cached: GlobalState, initialState: GlobalState) {
if (!cached.activeReactions) { if (!cached.activeReactions) {
cached.activeReactions = {}; cached.activeReactions = {};
} }
if (!cached.pollModal) {
cached.pollModal = {
isOpen: false,
};
}
} }
function updateCache() { function updateCache() {

View File

@ -1,5 +1,5 @@
import { import {
ApiAudio, ApiMediaFormat, ApiMessage, ApiMessageSearchType, ApiPhoto, ApiVideo, ApiDimensions, ApiLocation, ApiAudio, ApiMediaFormat, ApiMessage, ApiMessageSearchType, ApiPhoto, ApiVideo, ApiDimensions, ApiLocation, ApiGame,
} from '../../api/types'; } from '../../api/types';
import { IS_OPUS_SUPPORTED, IS_PROGRESSIVE_SUPPORTED, IS_SAFARI } from '../../util/environment'; import { IS_OPUS_SUPPORTED, IS_PROGRESSIVE_SUPPORTED, IS_SAFARI } from '../../util/environment';
@ -252,6 +252,26 @@ export function getMessageMediaHash(
return undefined; return undefined;
} }
export function getGamePreviewPhotoHash(game: ApiGame) {
const { photo } = game;
if (photo) {
return `photo${photo.id}?size=x`;
}
return undefined;
}
export function getGamePreviewVideoHash(game: ApiGame) {
const { document } = game;
if (document) {
return `document${document.id}`;
}
return undefined;
}
function getVideoOrAudioBaseHash(media: ApiAudio | ApiVideo, base: string) { function getVideoOrAudioBaseHash(media: ApiAudio | ApiVideo, base: string) {
if (IS_PROGRESSIVE_SUPPORTED && IS_SAFARI) { if (IS_PROGRESSIVE_SUPPORTED && IS_SAFARI) {
return `${base}?fileSize=${media.size}&mimeType=${media.mimeType}`; return `${base}?fileSize=${media.size}&mimeType=${media.mimeType}`;

View File

@ -118,6 +118,7 @@ export function getMessageSummaryDescription(
poll, poll,
invoice, invoice,
location, location,
game,
} = message.content; } = message.content;
let summary: string | TextPart[] | undefined; let summary: string | TextPart[] | undefined;
@ -178,6 +179,10 @@ export function getMessageSummaryDescription(
summary = lang('Message.LiveLocation'); summary = lang('Message.LiveLocation');
} }
if (game) {
summary = `🎮 ${game.title}`;
}
const reaction = !noReactions && getMessageRecentReaction(message); const reaction = !noReactions && getMessageRecentReaction(message);
if (summary && reaction) { if (summary && reaction) {
summary = `to your "${summary}"`; summary = `to your "${summary}"`;

View File

@ -44,7 +44,7 @@ export function getMessageOriginalId(message: ApiMessage) {
export function getMessageText(message: ApiMessage) { export function getMessageText(message: ApiMessage) {
const { const {
text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, game,
} = message.content; } = message.content;
if (text) { if (text) {
@ -52,7 +52,7 @@ export function getMessageText(message: ApiMessage) {
} }
if (sticker || photo || video || audio || voice || document if (sticker || photo || video || audio || voice || document
|| contact || poll || webPage || invoice || location) { || contact || poll || webPage || invoice || location || game) {
return undefined; return undefined;
} }

View File

@ -202,4 +202,8 @@ export const INITIAL_STATE: GlobalState = {
statistics: { statistics: {
byChatId: {}, byChatId: {},
}, },
pollModal: {
isOpen: false,
},
}; };

View File

@ -165,3 +165,10 @@ export function selectSendAs(global: GlobalState, chatId: string) {
return selectUser(global, id) || selectChat(global, id); return selectUser(global, id) || selectChat(global, id);
} }
export function selectRequestedText(global: GlobalState, chatId: string) {
if (global.openChatWithText?.chatId === chatId) {
return global.openChatWithText.text;
}
return undefined;
}

View File

@ -642,8 +642,8 @@ export function selectIsPollResultsOpen(global: GlobalState) {
} }
export function selectIsForwardModalOpen(global: GlobalState) { export function selectIsForwardModalOpen(global: GlobalState) {
const { forwardMessages } = global; const { forwardMessages, switchBotInline } = global;
return Boolean(forwardMessages.isModalShown); return Boolean(switchBotInline || forwardMessages.isModalShown);
} }
export function selectCommonBoxChatId(global: GlobalState, messageId: number) { export function selectCommonBoxChatId(global: GlobalState, messageId: number) {

View File

@ -28,7 +28,9 @@ import {
ApiSponsoredMessage, ApiSponsoredMessage,
ApiChannelStatistics, ApiChannelStatistics,
ApiGroupStatistics, ApiGroupStatistics,
ApiPaymentFormNativeParams, ApiUpdate, ApiPaymentFormNativeParams,
ApiUpdate,
ApiKeyboardButton,
} from '../api/types'; } from '../api/types';
import { import {
FocusDirection, FocusDirection,
@ -114,7 +116,6 @@ export type GlobalState = {
isChatInfoShown: boolean; isChatInfoShown: boolean;
isStatisticsShown?: boolean; isStatisticsShown?: boolean;
isLeftColumnShown: boolean; isLeftColumnShown: boolean;
isPollModalOpen?: boolean;
newChatMembersProgress?: NewChatMembersProgress; newChatMembersProgress?: NewChatMembersProgress;
uiReadyState: 0 | 1 | 2; uiReadyState: 0 | 1 | 2;
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
@ -407,6 +408,7 @@ export type GlobalState = {
fromChatId?: string; fromChatId?: string;
messageIds?: number[]; messageIds?: number[];
toChatId?: string; toChatId?: string;
withMyScore?: boolean;
}; };
pollResults: { pollResults: {
@ -516,6 +518,27 @@ export type GlobalState = {
userId?: string; userId?: string;
isByPhoneNumber?: boolean; isByPhoneNumber?: boolean;
}; };
openedGame?: {
url: string;
chatId: string;
messageId: number;
};
switchBotInline?: {
query: string;
botUsername: string;
};
openChatWithText?: {
chatId: string;
text: string;
};
pollModal: {
isOpen: boolean;
isQuiz?: boolean;
};
}; };
export interface ActionPayloads { export interface ActionPayloads {
@ -531,6 +554,13 @@ export interface ActionPayloads {
shouldReplaceHistory?: boolean; shouldReplaceHistory?: boolean;
}; };
openChatWithText: {
chatId: string;
text: string;
};
resetOpenChatWithText: {};
// Messages // Messages
setEditingDraft: { setEditingDraft: {
text?: ApiFormattedText; text?: ApiFormattedText;
@ -615,6 +645,34 @@ export interface ActionPayloads {
isMuted?: boolean; isMuted?: boolean;
shouldSharePhoneNumber?: boolean; shouldSharePhoneNumber?: boolean;
}; };
// Bots
clickBotInlineButton: {
messageId: number;
button: ApiKeyboardButton;
};
switchBotInline: {
messageId: number;
query: string;
isSamePeer?: boolean;
};
resetSwitchBotInline: {};
// Misc
openGame: {
url: string;
chatId: string;
messageId: number;
};
closeGame: {};
openPollModal: {
isQuiz?: boolean;
};
closePollModal: {};
} }
export type NonTypedActionNames = ( export type NonTypedActionNames = (
@ -697,10 +755,9 @@ export type NonTypedActionNames = (
'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' | 'loadGreetingStickers' | 'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' | 'loadGreetingStickers' |
'openStickerSetShortName' | 'openStickerSetShortName' |
// bots // bots
'clickInlineButton' | 'sendBotCommand' | 'loadTopInlineBots' | 'queryInlineBot' | 'sendInlineBotResult' | 'sendBotCommand' | 'loadTopInlineBots' | 'queryInlineBot' | 'sendInlineBotResult' |
'resetInlineBot' | 'restartBot' | 'startBot' | 'resetInlineBot' | 'restartBot' | 'startBot' |
// misc // misc
'openPollModal' | 'closePollModal' |
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' |
'setDeviceToken' | 'deleteDeviceToken' | 'setDeviceToken' | 'deleteDeviceToken' |
'checkVersionNotification' | 'createServiceNotification' | 'checkVersionNotification' | 'createServiceNotification' |

View File

@ -6,7 +6,7 @@ import { ApiSendMessageAction } from '../api/types';
import { SEND_MESSAGE_ACTION_INTERVAL } from '../config'; import { SEND_MESSAGE_ACTION_INTERVAL } from '../config';
import { throttle } from '../util/schedulers'; import { throttle } from '../util/schedulers';
const useSendMessageAction = (chatId: string, threadId?: number) => { const useSendMessageAction = (chatId?: string, threadId?: number) => {
return useMemo(() => { return useMemo(() => {
return throttle((action: ApiSendMessageAction) => { return throttle((action: ApiSendMessageAction) => {
getActions().sendMessageAction({ chatId, threadId, action }); getActions().sendMessageAction({ chatId, threadId, action });

View File

@ -1,4 +1,3 @@
// eslint-disable-next-line import/no-named-default
import { default as Api } from '../tl/api'; import { default as Api } from '../tl/api';
import TelegramClient from './TelegramClient'; import TelegramClient from './TelegramClient';
import utils from '../Utils'; import utils from '../Utils';

45
src/util/PopupManager.ts Normal file
View File

@ -0,0 +1,45 @@
import { IS_ANDROID, IS_IOS } from './environment';
const SHOULD_PRE_OPEN = IS_IOS || IS_ANDROID;
export default class PopupManager {
private preOpened?: WindowProxy | null;
constructor(private features?: string, private onFail?: NoneToVoidFunction) {
}
preOpenIfNeeded() {
if (!SHOULD_PRE_OPEN) return;
this.preOpened = window.open('about:blank', undefined, this.features);
if (this.preOpened) {
this.preOpened.blur();
} else {
this.onFail?.();
}
}
open(url: string) {
if (this.preOpened) {
this.preOpened!.location.href = url;
this.preOpened!.focus();
this.preOpened = undefined;
return;
}
if (!SHOULD_PRE_OPEN) {
const popup = window.open(url, undefined, this.features);
if (popup) {
popup.focus();
} else {
this.onFail?.();
}
}
}
cancelPreOpen() {
this.preOpened?.close();
this.preOpened = undefined;
}
}