Mini apps main app button and previews (#4866)

Co-authored-by: Ponama <anastasiiadmm@gmail.com>
This commit is contained in:
Alexander Zinchuk 2024-08-29 15:52:32 +02:00
parent 8433012a88
commit ff7c6dca5c
23 changed files with 411 additions and 24 deletions

View File

@ -110,7 +110,7 @@ function buildApiAttachMenuIcon(icon: GramJs.AttachMenuBotIcon): ApiAttachBotIco
export function buildApiBotInfo(botInfo: GramJs.BotInfo, chatId: string): ApiBotInfo {
const {
description, descriptionPhoto, descriptionDocument, userId, commands, menuButton,
description, descriptionPhoto, descriptionDocument, userId, commands, menuButton, hasPreviewMedias,
} = botInfo;
const botId = userId && buildApiPeerId(userId, 'user');
@ -126,6 +126,7 @@ export function buildApiBotInfo(botInfo: GramJs.BotInfo, chatId: string): ApiBot
photo,
menuButton: buildApiBotMenuButton(menuButton),
commands: commandsArray?.length ? commandsArray : undefined,
hasPreviewMedia: hasPreviewMedias,
};
}

View File

@ -81,6 +81,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
type: userType,
firstName,
lastName,
hasMainMiniApp: Boolean(mtpUser.botHasMainApp),
canEditBot: botCanEdit,
...(userType === 'userTypeBot' && { canBeInvitedToGroup: !mtpUser.botNochats }),
...(usernames && { usernames }),

View File

@ -3,6 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiBotApp,
ApiBotPreviewMedia,
ApiChat,
ApiInputMessageReplyInfo,
ApiPeer,
@ -23,6 +24,7 @@ import {
} from '../apiBuilders/bots';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { omitVirtualClassFields } from '../apiBuilders/helpers';
import { buildMessageMediaContent } from '../apiBuilders/messageContent';
import { buildApiUrlAuthResult } from '../apiBuilders/misc';
import { buildApiUser } from '../apiBuilders/users';
import {
@ -238,6 +240,35 @@ export async function requestWebView({
return undefined;
}
export async function requestMainWebView({
peer,
bot,
startParam,
theme,
}: {
peer: ApiPeer;
bot: ApiUser;
startParam?: string;
theme?: ApiThemeParameters;
}) {
const result = await invokeRequest(new GramJs.messages.RequestMainWebView({
peer: buildInputPeer(peer.id, peer.accessHash),
bot: buildInputPeer(bot.id, bot.accessHash),
startParam,
themeParams: theme ? buildInputThemeParams(theme) : undefined,
platform: WEB_APP_PLATFORM,
}));
if (!(result instanceof GramJs.WebViewResultUrl)) {
return undefined;
}
return {
url: result.url,
queryId: result.queryId?.toString(),
};
}
export async function requestSimpleWebView({
bot,
url,
@ -551,6 +582,22 @@ export async function invokeWebViewCustomMethod({
}
}
export async function fetchPreviewMedias({ bot } : { bot: ApiUser }) {
const result = await invokeRequest(new GramJs.bots.GetPreviewMedias({
bot: buildInputPeer(bot.id, bot.accessHash),
}));
if (!result) return undefined;
const previews: ApiBotPreviewMedia[] = result.map((preview) => {
return {
content: buildMessageMediaContent(preview.media)!,
date: preview.date,
};
});
return previews;
}
function processInlineBotResult(queryId: string, results: GramJs.TypeBotInlineResult[]) {
return results.map((result) => {
if (result instanceof GramJs.BotInlineMediaResult) {

View File

@ -65,8 +65,8 @@ export {
} from './twoFaSettings';
export {
answerCallbackButton, setBotInfo, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults,
sendInlineBotResult, startBot, fetchPopularAppBots, fetchTopBotApps,
answerCallbackButton, setBotInfo, fetchTopInlineBots, fetchPreviewMedias, fetchInlineBot, fetchInlineBotResults,
sendInlineBotResult, startBot, requestMainWebView, fetchPopularAppBots, fetchTopBotApps,
requestWebView, requestSimpleWebView, sendWebViewData, prolongWebView, loadAttachBots, toggleAttachBot, fetchBotApp,
requestBotUrlAuth, requestLinkUrlAuth, acceptBotUrlAuth, acceptLinkUrlAuth, loadAttachBot, requestAppWebView,
allowBotSendMessages, fetchBotCanSendMessage, invokeWebViewCustomMethod,

View File

@ -1,6 +1,6 @@
import type {
ApiDimensions,
ApiPhoto, ApiSticker, ApiThumbnail, ApiVideo,
ApiPhoto, ApiSticker, ApiThumbnail, ApiVideo, MediaContainer,
} from './messages';
export type ApiInlineResultType = (
@ -74,4 +74,9 @@ export interface ApiBotInfo {
photo?: ApiPhoto;
gif?: ApiVideo;
menuButton: ApiBotMenuButton;
hasPreviewMedia?: true;
}
export interface ApiBotPreviewMedia extends MediaContainer {
date: number;
}

View File

@ -40,6 +40,7 @@ export interface ApiUser {
maxStoryId?: number;
color?: ApiPeerColor;
canEditBot?: boolean;
hasMainMiniApp?: boolean;
botActiveUsers?: number;
}

View File

@ -1271,6 +1271,9 @@
"MenuInstallApp" = "Install App";
"RemoveEffect" = "Remove effect";
"ReplyInPrivateMessage" = "Reply In Private Message";
"ProfileOpenAppAbout" = "By launching this mini app, you agree to the {terms}.";
"ProfileOpenAppTerms" = "Terms of Service for Mini Apps";
"ProfileBotOpenAppInfoLink" = "https://telegram.org/tos/mini-apps";
"MonetizationInfoTONTitle" = "What is 💎 TON?";
"ChannelEarnLearnCoinAbout" = "TON is a blockchain platform and cryptocurrency that Telegram uses for its high speed and low commissions on transactions. {link}";
"MonetizationBalanceZeroInfo" = "You will be able to collect rewards using Fragment, a third-party platform used by advertisers to pay for ads. {link}";

View File

@ -0,0 +1,82 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useRef } from '../../lib/teact/teact';
import type { ApiBotPreviewMedia } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import {
getMessageMediaHash, getMessageMediaThumbDataUri,
} from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { formatMediaDuration } from '../../util/dates/dateFormat';
import stopEvent from '../../util/stopEvent';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia';
import useMediaTransition from '../../hooks/useMediaTransition';
import './Media.scss';
type OwnProps = {
media: ApiBotPreviewMedia;
idPrefix?: string;
isProtected?: boolean;
observeIntersection?: ObserveFn;
onClick: (index: number) => void;
index: number;
};
const PreviewMedia: FC<OwnProps> = ({
media,
idPrefix = 'preview-media',
isProtected,
observeIntersection,
onClick,
index,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const thumbDataUri = getMessageMediaThumbDataUri(media);
const mediaBlobUrl = useMedia(getMessageMediaHash(media, 'preview'), !isIntersecting);
const transitionClassNames = useMediaTransition(mediaBlobUrl);
const video = media.content.video;
const handleClick = useLastCallback(() => {
onClick(index);
});
return (
<div
ref={ref}
id={`${idPrefix}${index}`}
className="Media scroll-item"
onClick={handleClick}
>
<img
src={thumbDataUri}
className="media-miniature"
alt=""
draggable={!isProtected}
decoding="async"
onContextMenu={isProtected ? stopEvent : undefined}
/>
<img
src={mediaBlobUrl}
className={buildClassName('full-media', 'media-miniature', transitionClassNames)}
alt=""
draggable={!isProtected}
decoding="async"
onContextMenu={isProtected ? stopEvent : undefined}
/>
{video && <span className="video-duration">{video.isGif ? 'GIF' : formatMediaDuration(video.duration)}</span>}
{isProtected && <span className="protector" />}
</div>
);
};
export default memo(PreviewMedia);

View File

@ -24,6 +24,11 @@
margin-bottom: 0;
}
.sectionInfo {
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.personalChannelSubscribers {
grid-column: 2;
grid-row: 1;
@ -36,3 +41,8 @@
grid-column: 1 / span 2;
grid-row: 2;
}
.openAppButton {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}

View File

@ -32,20 +32,24 @@ import { copyTextToClipboard } from '../../../util/clipboard';
import { formatPhoneNumberWithCode } from '../../../util/phoneNumber';
import { debounce } from '../../../util/schedulers';
import stopEvent from '../../../util/stopEvent';
import { extractCurrentThemeParams } from '../../../util/themeStyle';
import { ChatAnimationTypes } from '../../left/main/hooks';
import formatUsername from '../helpers/formatUsername';
import renderText from '../helpers/renderText';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useOldLang from '../../../hooks/useOldLang';
import useDevicePixelRatio from '../../../hooks/window/useDevicePixelRatio';
import Chat from '../../left/main/Chat';
import Button from '../../ui/Button';
import ListItem from '../../ui/ListItem';
import Skeleton from '../../ui/placeholder/Skeleton';
import Switcher from '../../ui/Switcher';
import SafeLink from '../SafeLink';
import BusinessHours from './BusinessHours';
import UserBirthday from './UserBirthday';
@ -70,6 +74,7 @@ type StateProps = {
topicLink?: string;
hasSavedMessages?: boolean;
personalChannel?: ApiChat;
hasMainMiniApp?: boolean;
};
const DEFAULT_MAP_CONFIG = {
@ -95,6 +100,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
topicLink,
hasSavedMessages,
personalChannel,
hasMainMiniApp,
}) => {
const {
showNotification,
@ -104,6 +110,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
openSavedDialog,
openMapModal,
requestCollectibleInfo,
requestMainWebView,
} = getActions();
const {
@ -120,7 +127,8 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
personalChannelMessageId,
birthday,
} = userFullInfo || {};
const lang = useOldLang();
const oldLang = useOldLang();
const lang = useLang();
const [areNotificationsEnabled, setAreNotificationsEnabled] = useState(!isMuted);
@ -177,7 +185,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
const { address, geo } = businessLocation!;
if (!geo) {
copyTextToClipboard(address);
showNotification({ message: lang('BusinessLocationCopied') });
showNotification({ message: oldLang('BusinessLocationCopied') });
return;
}
@ -220,7 +228,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
requestCollectibleInfo({ collectible: phoneNumber, peerId: peerId!, type: 'phone' });
return;
}
copy(formattedNumber!, lang('Phone'));
copy(formattedNumber!, oldLang('Phone'));
});
const handleUsernameClick = useLastCallback((username: ApiUsername, isChat?: boolean) => {
@ -228,9 +236,35 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
requestCollectibleInfo({ collectible: username.username, peerId: peerId!, type: 'username' });
return;
}
copy(formatUsername(username.username, isChat), lang(isChat ? 'Link' : 'Username'));
copy(formatUsername(username.username, isChat), oldLang(isChat ? 'Link' : 'Username'));
});
const handleOpenApp = useLastCallback(() => {
if (!chat) {
return;
}
const botId = user?.id;
if (!botId) {
return;
}
const theme = extractCurrentThemeParams();
requestMainWebView({
botId,
peerId: botId,
theme,
shouldMarkBotTrusted: true,
});
});
const appTermsInfo = lang('ProfileOpenAppAbout', {
terms: (
<SafeLink
text={lang('ProfileOpenAppTerms')}
url={lang('ProfileBotOpenAppInfoLink')}
/>
),
}, { withNodes: true });
if (!chat || chat.isRestricted || (isSelf && !isInSettings)) {
return undefined;
}
@ -239,7 +273,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
const [mainUsername, ...otherUsernames] = usernameList;
const usernameLinks = otherUsernames.length
? (lang('UsernameAlso', '%USERNAMES%') as string)
? (oldLang('UsernameAlso', '%USERNAMES%') as string)
.split('%')
.map((s) => {
return (s === 'USERNAMES' ? (
@ -282,7 +316,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
<span className="title" dir="auto">{formatUsername(mainUsername.username, isChat)}</span>
<span className="subtitle">
{usernameLinks && <span className="other-usernames">{usernameLinks}</span>}
{lang(isChat ? 'Link' : 'Username')}
{oldLang(isChat ? 'Link' : 'Username')}
</span>
</ListItem>
);
@ -292,9 +326,9 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
<div className="ChatExtra">
{personalChannel && (
<div className={styles.personalChannel}>
<h3 className={styles.personalChannelTitle}>{lang('ProfileChannel')}</h3>
<h3 className={styles.personalChannelTitle}>{oldLang('ProfileChannel')}</h3>
<span className={styles.personalChannelSubscribers}>
{lang('Subscribers', personalChannel.membersCount, 'i')}
{oldLang('Subscribers', personalChannel.membersCount, 'i')}
</span>
<Chat
chatId={personalChannel.id}
@ -310,7 +344,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
<ListItem icon="phone" multiline narrow ripple onClick={handlePhoneClick}>
<span className="title" dir="auto">{formattedNumber}</span>
<span className="subtitle">{lang('Phone')}</span>
<span className="subtitle">{oldLang('Phone')}</span>
</ListItem>
)}
{activeUsernames && renderUsernames(activeUsernames)}
@ -331,7 +365,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
])
}
</span>
<span className="subtitle">{lang(userId ? 'UserBio' : 'Info')}</span>
<span className="subtitle">{oldLang(userId ? 'UserBio' : 'Info')}</span>
</ListItem>
)}
{activeChatUsernames && !isTopicInfo && renderUsernames(activeChatUsernames, true)}
@ -342,18 +376,36 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
narrow
ripple
// eslint-disable-next-line react/jsx-no-bind
onClick={() => copy(link, lang('SetUrlPlaceholder'))}
onClick={() => copy(link, oldLang('SetUrlPlaceholder'))}
>
<div className="title">{link}</div>
<span className="subtitle">{lang('SetUrlPlaceholder')}</span>
<span className="subtitle">{oldLang('SetUrlPlaceholder')}</span>
</ListItem>
)}
{birthday && (
<UserBirthday key={peerId} birthday={birthday} user={user!} isInSettings={isInSettings} />
)}
{ hasMainMiniApp && (
<ListItem
multiline
isStatic
narrow
>
<Button
className={styles.openAppButton}
size="smaller"
onClick={handleOpenApp}
>
{oldLang('ProfileBotOpenApp')}
</Button>
<div className={styles.sectionInfo}>
{appTermsInfo}
</div>
</ListItem>
)}
{!isInSettings && (
<ListItem icon="unmute" narrow ripple onClick={handleNotificationChange}>
<span>{lang('Notifications')}</span>
<span>{oldLang('Notifications')}</span>
<Switcher
id="group-notifications"
label={userId ? 'Toggle User Notifications' : 'Toggle Chat Notifications'}
@ -375,12 +427,12 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
onClick={handleClickLocation}
>
<div className="title">{businessLocation.address}</div>
<span className="subtitle">{lang('BusinessProfileLocation')}</span>
<span className="subtitle">{oldLang('BusinessProfileLocation')}</span>
</ListItem>
)}
{hasSavedMessages && !isInSettings && (
<ListItem icon="saved-messages" narrow ripple onClick={handleOpenSavedDialog}>
<span>{lang('SavedMessagesTab')}</span>
<span>{oldLang('SavedMessagesTab')}</span>
</ListItem>
)}
</div>
@ -418,6 +470,8 @@ export default memo(withGlobal<OwnProps>(
? selectChat(global, userFullInfo.personalChannelId)
: undefined;
const hasMainMiniApp = user?.hasMainMiniApp;
return {
phoneCodeList,
chat,
@ -431,6 +485,7 @@ export default memo(withGlobal<OwnProps>(
topicLink,
hasSavedMessages,
personalChannel,
hasMainMiniApp,
};
},
)(ChatExtra));

View File

@ -299,6 +299,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe
mediaSelector = '.full-media';
break;
case MediaViewerOrigin.PreviewMedia:
containerSelector = `#preview-media${index}`;
mediaSelector = 'img';
break;
case MediaViewerOrigin.SharedMedia:
containerSelector = `#shared-media${getMessageHtmlId(message!.id, index)}`;
mediaSelector = 'img';
@ -358,6 +363,7 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
case MediaViewerOrigin.Inline:
case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.StarsTransaction:
case MediaViewerOrigin.PreviewMedia:
ghost.classList.add('rounded-corners');
break;

View File

@ -71,7 +71,8 @@
&.storiesArchive-list,
&.stories-list,
&.media-list {
&.media-list,
&.previewMedia-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-auto-rows: 1fr;

View File

@ -6,6 +6,7 @@ import React, {
import { getActions, withGlobal } from '../../global';
import type {
ApiBotPreviewMedia,
ApiChat,
ApiChatMember,
ApiMessage,
@ -52,6 +53,7 @@ import {
selectTabState,
selectTheme,
selectUser,
selectUserFullInfo,
} from '../../global/selectors';
import { selectPremiumLimit } from '../../global/selectors/limits';
import buildClassName from '../../util/buildClassName';
@ -77,6 +79,7 @@ import Document from '../common/Document';
import GroupChatInfo from '../common/GroupChatInfo';
import Media from '../common/Media';
import NothingFound from '../common/NothingFound';
import PreviewMedia from '../common/PreviewMedia';
import PrivateChatInfo from '../common/PrivateChatInfo';
import ChatExtra from '../common/profile/ChatExtra';
import ProfileInfo from '../common/ProfileInfo';
@ -113,6 +116,7 @@ type StateProps = {
hasCommonChatsTab?: boolean;
hasStoriesTab?: boolean;
hasMembersTab?: boolean;
hasPreviewMediaTab?: boolean;
areMembersHidden?: boolean;
canAddMembers?: boolean;
canDeleteMembers?: boolean;
@ -133,6 +137,7 @@ type StateProps = {
nextProfileTab?: ProfileTabType;
shouldWarnAboutSvg?: boolean;
similarChannels?: string[];
botPreviewMedia? : ApiBotPreviewMedia[];
isCurrentUserPremium?: boolean;
limitSimilarChannels: number;
isTopicInfo?: boolean;
@ -174,6 +179,8 @@ const Profile: FC<OwnProps & StateProps> = ({
hasCommonChatsTab,
hasStoriesTab,
hasMembersTab,
hasPreviewMediaTab,
botPreviewMedia,
areMembersHidden,
canAddMembers,
canDeleteMembers,
@ -210,6 +217,7 @@ const Profile: FC<OwnProps & StateProps> = ({
loadStoriesArchive,
openPremiumModal,
loadChannelRecommendations,
loadPreviewMedias,
} = getActions();
// eslint-disable-next-line no-null/no-null
@ -229,6 +237,9 @@ const Profile: FC<OwnProps & StateProps> = ({
...(hasMembersTab ? [{
type: 'members' as const, title: isChannel ? 'ChannelSubscribers' : 'GroupMembers',
}] : []),
...(hasPreviewMediaTab ? [{
type: 'previewMedia' as const, title: 'ProfileBotPreviewTab',
}] : []),
...TABS,
// TODO The filter for voice messages currently does not work
// in forum topics. Return it when it's fixed on the server side.
@ -240,6 +251,7 @@ const Profile: FC<OwnProps & StateProps> = ({
]), [
hasCommonChatsTab,
hasMembersTab,
hasPreviewMediaTab,
hasStoriesTab,
isChannel,
isTopicInfo,
@ -274,6 +286,12 @@ const Profile: FC<OwnProps & StateProps> = ({
setActiveTab(index);
}, []);
useEffect(() => {
if (hasPreviewMediaTab && !botPreviewMedia) {
loadPreviewMedias({ botId: chatId });
}
}, [chatId, botPreviewMedia, hasPreviewMediaTab]);
useEffect(() => {
if (isChannel && !similarChannels) {
loadChannelRecommendations({ chatId });
@ -364,6 +382,15 @@ const Profile: FC<OwnProps & StateProps> = ({
});
});
const handleSelectPreviewMedia = useLastCallback((index: number) => {
openMediaViewer({
standaloneMedia: botPreviewMedia?.flatMap((item) => item?.content.photo
|| item?.content.video).filter(Boolean),
origin: MediaViewerOrigin.PreviewMedia,
mediaIndex: index,
});
});
const handlePlayAudio = useLastCallback((messageId: number) => {
openAudioPlayer({ chatId: profileId, messageId });
});
@ -416,7 +443,7 @@ const Profile: FC<OwnProps & StateProps> = ({
if (isFirstTab) {
renderingDelay = !isRightColumnShown ? HIDDEN_RENDER_DELAY : 0;
// @optimization Used to delay first render of secondary tabs while animating
} else if (!viewportIds) {
} else if (!viewportIds && !botPreviewMedia) {
renderingDelay = SLIDE_TRANSITION_DURATION;
}
const canRenderContent = useAsyncRendering([chatId, threadId, resultType, renderingActiveTab], renderingDelay);
@ -438,7 +465,7 @@ const Profile: FC<OwnProps & StateProps> = ({
);
}
if (!viewportIds || !canRenderContent || !messagesById) {
if ((!viewportIds && !botPreviewMedia) || !canRenderContent || !messagesById) {
const noSpinner = isFirstTab && !canRenderContent;
const forceRenderHiddenMembers = Boolean(resultType === 'members' && areMembersHidden);
@ -450,7 +477,7 @@ const Profile: FC<OwnProps & StateProps> = ({
);
}
if (!viewportIds.length) {
if (viewportIds && !viewportIds?.length) {
let text: string;
switch (resultType) {
@ -595,6 +622,17 @@ const Profile: FC<OwnProps & StateProps> = ({
<GroupChatInfo chatId={id} />
</ListItem>
))
) : resultType === 'previewMedia' ? (
botPreviewMedia!.map((media, i) => (
<PreviewMedia
key={media.date}
media={media}
isProtected={isChatProtected}
observeIntersection={observeIntersectionForMedia}
onClick={handleSelectPreviewMedia}
index={i}
/>
))
) : resultType === 'similarChannels' ? (
<div key={resultType}>
{(viewportIds as string[])!.map((channelId, i) => (
@ -739,6 +777,11 @@ export default memo(withGlobal<OwnProps>(
const peer = user || chat;
const peerFullInfo = selectPeerFullInfo(global, chatId);
const userFullInfo = selectUserFullInfo(global, chatId);
const hasPreviewMediaTab = userFullInfo?.botInfo?.hasPreviewMedia;
const botPreviewMedia = global.users.previewMediaByBotId[chatId];
const hasStoriesTab = peer && (user?.isSelf || (!peer.areStoriesHidden && peerFullInfo?.hasPinnedStories))
&& !isSavedDialog;
const peerStories = hasStoriesTab ? selectPeerStories(global, peer.id) : undefined;
@ -757,6 +800,7 @@ export default memo(withGlobal<OwnProps>(
hasCommonChatsTab,
hasStoriesTab,
hasMembersTab,
hasPreviewMediaTab,
areMembersHidden,
canAddMembers,
canDeleteMembers,
@ -776,6 +820,7 @@ export default memo(withGlobal<OwnProps>(
forceScrollProfileTab: selectTabState(global).forceScrollProfileTab,
shouldWarnAboutSvg: global.settings.byKey.shouldWarnAboutSvg,
similarChannels: similarChannelIds,
botPreviewMedia,
isCurrentUserPremium,
isTopicInfo,
isSavedDialog,

View File

@ -612,6 +612,88 @@ addActionHandler('requestWebView', async (global, actions, payload): Promise<voi
setGlobal(global);
});
addActionHandler('requestMainWebView', async (global, actions, payload): Promise<void> => {
const {
botId, peerId, theme, startParam, shouldMarkBotTrusted,
tabId = getCurrentTabId(),
} = payload;
const bot = selectUser(global, botId);
if (!bot) return;
const peer = selectPeer(global, peerId);
if (!peer) return;
if (!selectIsTrustedBot(global, botId)) {
if (shouldMarkBotTrusted) {
actions.markBotTrusted({ botId, isWriteAllowed: true, tabId });
} else {
global = updateTabState(global, {
botTrustRequest: {
botId,
type: 'webApp',
onConfirm: {
action: 'requestMainWebView',
payload,
},
},
}, tabId);
setGlobal(global);
return;
}
}
const result = await callApi('requestMainWebView', {
bot,
peer,
theme,
startParam,
});
if (!result) {
return;
}
const { url: webViewUrl, queryId } = result;
global = getGlobal();
global = updateTabState(global, {
webApp: {
url: webViewUrl,
botId,
queryId,
buttonText: '',
},
}, tabId);
setGlobal(global);
});
addActionHandler('loadPreviewMedias', async (global, actions, payload): Promise<void> => {
const {
botId,
} = payload;
const bot = selectUser(global, botId);
if (!bot) return;
const medias = await callApi('fetchPreviewMedias', {
bot,
});
global = getGlobal();
if (medias) {
global = {
...global,
users: {
...global.users,
previewMediaByBotId: {
...global.users.previewMediaByBotId,
[botId]: medias,
},
},
};
setGlobal(global);
}
});
addActionHandler('requestAppWebView', async (global, actions, payload): Promise<void> => {
const {
botId, appName, startApp, theme, isWriteAllowed, isFromConfirm,

View File

@ -1485,6 +1485,20 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
});
return;
}
if (startApp !== undefined && !webAppName) {
const theme = extractCurrentThemeParams();
const chatByUsername = await fetchChatByUsername(global, username);
global = getGlobal();
const user = chatByUsername && selectUser(global, chatByUsername.id);
if (!chatByUsername || !chat || !user?.hasMainMiniApp) return;
actions.requestMainWebView({
botId: chatByUsername.id,
peerId: chat.id,
theme,
tabId,
});
return;
}
if (!isWebApp) {
await openChatByUsername(
global, actions, {

View File

@ -253,6 +253,9 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
cached.quickReplies = initialState.quickReplies;
}
if (!cached.users.previewMediaByBotId) {
cached.users.previewMediaByBotId = initialState.users.previewMediaByBotId;
}
if (!cached.chats.loadingParameters) {
cached.chats.loadingParameters = initialState.chats.loadingParameters;
}
@ -366,7 +369,11 @@ function reduceCustomEmojis<T extends GlobalState>(global: T): GlobalState['cust
}
function reduceUsers<T extends GlobalState>(global: T): GlobalState['users'] {
const { users: { byId, statusesById, fullInfoById }, currentUserId } = global;
const {
users: {
byId, statusesById, fullInfoById,
}, currentUserId,
} = global;
const currentChatIds = compact(
Object.values(global.byTabId)
.map(({ id: tabId }) => selectCurrentMessageList(global, tabId)),
@ -400,6 +407,7 @@ function reduceUsers<T extends GlobalState>(global: T): GlobalState['users'] {
byId: pick(byId, idsToSave),
statusesById: pick(statusesById, idsToSave),
fullInfoById: pick(fullInfoById, idsToSave),
previewMediaByBotId: {},
};
}

View File

@ -97,6 +97,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
byId: {},
statusesById: {},
fullInfoById: {},
previewMediaByBotId: {},
},
chats: {

View File

@ -6,6 +6,7 @@ import type {
ApiAvailableReaction,
ApiBoost,
ApiBoostsStatus,
ApiBotPreviewMedia,
ApiChannelMonetizationStatistics,
ApiChannelStatistics,
ApiChat,
@ -925,6 +926,7 @@ export type GlobalState = {
statusesById: Record<string, ApiUserStatus>;
// Obtained from GetFullUser / UserFullInfo
fullInfoById: Record<string, ApiUserFullInfo>;
previewMediaByBotId: Record<string, ApiBotPreviewMedia[]>;
};
chats: {
@ -2873,6 +2875,13 @@ export interface ActionPayloads {
isFromBotMenu?: boolean;
startParam?: string;
} & WithTabId;
requestMainWebView: {
botId: string;
peerId: string;
theme?: ApiThemeParameters;
startParam?: string;
shouldMarkBotTrusted?: boolean;
} & WithTabId;
prolongWebView: {
botId: string;
peerId: string;
@ -2898,6 +2907,9 @@ export interface ActionPayloads {
isWriteAllowed?: boolean;
isFromConfirm?: boolean;
} & WithTabId;
loadPreviewMedias: {
botId: string;
};
setWebAppPaymentSlug: {
slug?: string;
} & WithTabId;

View File

@ -1547,6 +1547,7 @@ messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vecto
messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector<int> random_id:Vector<long> = Updates;
messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects;
messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector<int> = Vector<FactCheck>;
messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult;
updates.getState#edd4882a = updates.State;
updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;
@ -1617,6 +1618,7 @@ bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:fla
bots.canSendMessage#1359f4e6 bot:InputUser = Bool;
bots.allowSendMessage#f132e3ef bot:InputUser = Updates;
bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON;
bots.getPreviewMedias#a2a5594d bot:InputUser = Vector<BotPreviewMedia>;
bots.getPopularAppBots#c2510192 offset:string limit:int = bots.PopularAppBots;
payments.getPaymentForm#37148dbb flags:# invoice:InputInvoice theme_params:flags.0?DataJSON = payments.PaymentForm;
payments.getPaymentReceipt#2478d1cc peer:InputPeer msg_id:int = payments.PaymentReceipt;

View File

@ -175,6 +175,7 @@
"messages.getQuickReplyMessages",
"messages.sendQuickReplyMessages",
"messages.getFactCheck",
"messages.requestMainWebView",
"updates.getState",
"updates.getDifference",
"updates.getChannelDifference",
@ -230,6 +231,7 @@
"bots.invokeWebViewCustomMethod",
"bots.getPopularAppBots",
"bots.setBotInfo",
"bots.getPreviewMedias",
"payments.getPaymentForm",
"payments.getPaymentReceipt",
"payments.validateRequestedInfo",

View File

@ -329,6 +329,7 @@ export enum MediaViewerOrigin {
SearchResult,
SuggestedAvatar,
StarsTransaction,
PreviewMedia,
}
export enum StoryViewerOrigin {
@ -392,6 +393,7 @@ export type ProfileTabType =
| 'members'
| 'commonChats'
| 'media'
| 'previewMedia'
| 'documents'
| 'links'
| 'audio'

View File

@ -1512,6 +1512,11 @@ export interface LangPair {
'MenuInstallApp': undefined;
'RemoveEffect': undefined;
'ReplyInPrivateMessage': undefined;
'ProfileOpenAppAbout': {
'terms': string;
};
'ProfileOpenAppTerms': undefined;
'ProfileBotOpenAppInfoLink': undefined;
'MonetizationInfoTONTitle': undefined;
'ChannelEarnLearnCoinAbout': {
'link': string | number;

View File

@ -511,4 +511,6 @@ export default {
SlowModeWait: 'Slow Mode — %d',
OpenMapWith: 'Open map with...',
FullDateTimeFormat: '%@, %@',
ProfileOpenAppTerms: 'Terms of Service for Mini Apps',
ProfileBotOpenAppInfoLink: 'https://telegram.org/tos/mini-apps',
} as ApiOldLangPack;