From 505f67674a48449fccb8a68eed4e1c94951a8a80 Mon Sep 17 00:00:00 2001
From: Alexander Zinchuk
Date: Thu, 19 Sep 2024 20:43:14 +0200
Subject: [PATCH] Sponsored Message: Implement media in ads (#4925)
---
src/api/gramjs/apiBuilders/messages.ts | 15 +-
src/api/gramjs/methods/messages.ts | 2 +-
src/api/types/messages.ts | 3 +-
.../common/helpers/renderMessageText.ts | 4 +-
src/components/mediaViewer/MediaViewer.tsx | 40 ++++-
.../mediaViewer/MediaViewerActions.tsx | 4 +-
.../mediaViewer/MediaViewerContent.tsx | 16 +-
.../mediaViewer/MediaViewerFooter.scss | 4 +
.../mediaViewer/MediaViewerFooter.tsx | 17 +-
.../mediaViewer/MediaViewerSlides.tsx | 5 +
.../mediaViewer/helpers/getViewableMedia.ts | 17 +-
.../mediaViewer/helpers/ghostAnimation.ts | 5 +
src/components/middle/MessageListContent.tsx | 8 +-
.../middle/message/SponsoredMessage.scss | 10 ++
.../middle/message/SponsoredMessage.tsx | 167 ++++++++++++++++--
src/global/actions/ui/mediaViewer.ts | 3 +-
src/global/helpers/messages.ts | 6 +-
src/global/selectors/messages.ts | 8 +-
src/global/selectors/ui.ts | 7 +-
src/global/types.ts | 2 +
src/types/index.ts | 1 +
src/util/keys/messageKey.ts | 13 +-
22 files changed, 307 insertions(+), 50 deletions(-)
diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts
index a9a74fadc..3ccf3dd68 100644
--- a/src/api/gramjs/apiBuilders/messages.ts
+++ b/src/api/gramjs/apiBuilders/messages.ts
@@ -79,7 +79,9 @@ export function setMessageBuilderCurrentUserId(_currentUserId: string) {
currentUserId = _currentUserId;
}
-export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): ApiSponsoredMessage | undefined {
+export function buildApiSponsoredMessage(
+ mtpMessage: GramJs.SponsoredMessage, chatId: string,
+): ApiSponsoredMessage | undefined {
const {
message, entities, randomId, recommended, sponsorInfo, additionalInfo, buttonText, canReport, title, url, color,
} = mtpMessage;
@@ -90,9 +92,14 @@ export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): A
photo = buildApiPhoto(mtpMessage.photo);
}
+ let media: MediaContent | undefined;
+ if (mtpMessage.media) {
+ media = buildMessageMediaContent(mtpMessage.media);
+ }
+
return {
+ chatId,
randomId: serializeBytes(randomId),
- text: buildMessageTextContent(message, entities),
expiresAt: Math.round(Date.now() / 1000) + SPONSORED_MESSAGE_CACHE_MS,
isRecommended: recommended,
sponsorInfo,
@@ -103,6 +110,10 @@ export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): A
url,
peerColor: color && buildApiPeerColor(color),
photo,
+ content: {
+ ...media,
+ text: buildMessageTextContent(message, entities),
+ },
};
}
diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts
index d29bf0680..d1f2ea2b8 100644
--- a/src/api/gramjs/methods/messages.ts
+++ b/src/api/gramjs/methods/messages.ts
@@ -1661,7 +1661,7 @@ export async function fetchSponsoredMessages({ chat }: { chat: ApiChat }) {
return undefined;
}
- const messages = result.messages.map(buildApiSponsoredMessage).filter(Boolean);
+ const messages = result.messages.map((message) => buildApiSponsoredMessage(message, chat.id)).filter(Boolean);
return {
messages,
diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts
index d0c605d18..9874b939f 100644
--- a/src/api/types/messages.ts
+++ b/src/api/types/messages.ts
@@ -763,9 +763,9 @@ export type ApiThreadInfo = ApiCommentsInfo | ApiMessageThreadInfo;
export type ApiMessageOutgoingStatus = 'read' | 'succeeded' | 'pending' | 'failed';
export type ApiSponsoredMessage = {
+ chatId: string;
randomId: string;
isRecommended?: true;
- text: ApiFormattedText;
expiresAt: number;
sponsorInfo?: string;
additionalInfo?: string;
@@ -774,6 +774,7 @@ export type ApiSponsoredMessage = {
title: string;
url: string;
photo?: ApiPhoto;
+ content: MediaContent;
peerColor?: ApiPeerColor;
};
diff --git a/src/components/common/helpers/renderMessageText.ts b/src/components/common/helpers/renderMessageText.ts
index 2ea0e64a4..e40a1fac4 100644
--- a/src/components/common/helpers/renderMessageText.ts
+++ b/src/components/common/helpers/renderMessageText.ts
@@ -1,4 +1,4 @@
-import type { ApiMessage } from '../../../api/types';
+import type { ApiMessage, ApiSponsoredMessage } from '../../../api/types';
import type { LangFn } from '../../../hooks/useOldLang';
import type { TextPart } from '../../../types';
import { ApiMessageEntityTypes } from '../../../api/types';
@@ -28,7 +28,7 @@ export function renderMessageText({
shouldRenderAsHtml,
isForMediaViewer,
} : {
- message: ApiMessage;
+ message: ApiMessage | ApiSponsoredMessage;
highlight?: string;
emojiSize?: number;
isSimple?: boolean;
diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx
index f6b1fd4de..36c1d8e1d 100644
--- a/src/components/mediaViewer/MediaViewer.tsx
+++ b/src/components/mediaViewer/MediaViewer.tsx
@@ -5,7 +5,7 @@ import { getActions, withGlobal } from '../../global';
import type {
ApiChat,
- ApiMessage, ApiPeer, ApiPhoto,
+ ApiMessage, ApiPeer, ApiPhoto, ApiSponsoredMessage,
} from '../../api/types';
import { type MediaViewerMedia, MediaViewerOrigin, type ThreadId } from '../../types';
@@ -25,7 +25,7 @@ import {
selectOutlyingListByMessageId,
selectPeer,
selectPerformanceSettingsValue,
- selectScheduledMessage,
+ selectScheduledMessage, selectSponsoredMessage,
selectTabState,
} from '../../global/selectors';
import { stopCurrentAudio } from '../../util/audioPlayer';
@@ -70,6 +70,7 @@ type StateProps = {
avatar?: ApiPhoto;
avatarOwner?: ApiPeer;
chatMessages?: Record;
+ sponsoredMessage?: ApiSponsoredMessage;
standaloneMedia?: MediaViewerMedia[];
mediaIndex?: number;
isHidden?: boolean;
@@ -95,6 +96,7 @@ const MediaViewer = ({
avatar,
avatarOwner,
chatMessages,
+ sponsoredMessage,
standaloneMedia,
mediaIndex,
withAnimation,
@@ -112,9 +114,11 @@ const MediaViewer = ({
toggleChatInfo,
searchChatMediaMessages,
loadMoreProfilePhotos,
+ clickSponsoredMessage,
+ openUrl,
} = getActions();
- const isOpen = Boolean(avatarOwner || message || standaloneMedia);
+ const isOpen = Boolean(avatarOwner || message || standaloneMedia || sponsoredMessage);
const { isMobile } = useAppLayout();
/* Animation */
@@ -128,7 +132,7 @@ const MediaViewer = ({
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
const currentItem = getMediaViewerItem({
- message, avatarOwner, standaloneMedia, mediaIndex,
+ message, avatarOwner, standaloneMedia, mediaIndex, sponsoredMessage,
});
const { media, isSingle } = getViewableMedia(currentItem) || {};
@@ -242,6 +246,14 @@ const MediaViewer = ({
}
});
+ const onSponsoredButtonClick = useLastCallback(() => {
+ if (!sponsoredMessage || !chatId) return;
+
+ clickSponsoredMessage({ chatId });
+ openUrl({ url: sponsoredMessage!.url });
+ closeMediaViewer();
+ });
+
const handleForward = useLastCallback(() => {
openForwardMenu({
fromChatId: chatId!,
@@ -296,6 +308,16 @@ const MediaViewer = ({
return undefined;
}
+ if (from.type === 'sponsoredMessage') {
+ const { message: fromSponsoredMessage, mediaIndex: fromSponsoredMessageIndex } = from;
+ const nextIndex = fromSponsoredMessageIndex! + direction;
+ if (nextIndex >= 0 && fromSponsoredMessage) {
+ return { type: 'sponsoredMessage', message: fromSponsoredMessage, mediaIndex: nextIndex };
+ }
+
+ return undefined;
+ }
+
const { message: fromMessage, mediaIndex: fromMediaIndex } = from;
const paidMedia = getMessagePaidMedia(fromMessage);
@@ -434,6 +456,7 @@ const MediaViewer = ({
selectItem={openMediaViewerItem}
isHidden={isHidden}
onFooterClick={handleFooterClick}
+ onSponsoredButtonClick={onSponsoredButtonClick}
/>
);
@@ -452,6 +475,7 @@ export default memo(withGlobal(
standaloneMedia,
mediaIndex,
isAvatarView,
+ isSponsoredMessage,
} = mediaViewer;
const withAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations');
@@ -492,6 +516,13 @@ export default memo(withGlobal(
}
}
+ let sponsoredMessage: ApiSponsoredMessage | undefined;
+ if (isSponsoredMessage && chatId) {
+ if (origin === MediaViewerOrigin.SponsoredMessage) {
+ sponsoredMessage = selectSponsoredMessage(global, chatId);
+ }
+ }
+
let chatMessages: Record | undefined;
if (chatId) {
@@ -531,6 +562,7 @@ export default memo(withGlobal(
origin,
message,
chatMessages,
+ sponsoredMessage,
collectedMessageIds,
withAnimation,
isHidden,
diff --git a/src/components/mediaViewer/MediaViewerActions.tsx b/src/components/mediaViewer/MediaViewerActions.tsx
index 1043f0a57..9f62a9fe2 100644
--- a/src/components/mediaViewer/MediaViewerActions.tsx
+++ b/src/components/mediaViewer/MediaViewerActions.tsx
@@ -180,7 +180,7 @@ const MediaViewerActions: FC = ({
return undefined;
}
- return isVideo ? (
+ return item?.type !== 'sponsoredMessage' && (isVideo ? (
- );
+ ));
}
const openDeleteModalHandler = useLastCallback(() => {
diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx
index 387b836cd..ee51e1fd9 100644
--- a/src/components/mediaViewer/MediaViewerContent.tsx
+++ b/src/components/mediaViewer/MediaViewerContent.tsx
@@ -2,7 +2,7 @@ import React, { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global';
import type {
- ApiDimensions, ApiMessage,
+ ApiDimensions, ApiMessage, ApiSponsoredMessage,
} from '../../api/types';
import type { MediaViewerOrigin } from '../../types';
import type { MediaViewerItem } from './helpers/getViewableMedia';
@@ -36,10 +36,11 @@ type OwnProps = {
isMoving?: boolean;
onClose: () => void;
onFooterClick: () => void;
+ onSponsoredButtonClick: () => void;
};
type StateProps = {
- textMessage?: ApiMessage;
+ textMessage?: ApiMessage | ApiSponsoredMessage;
origin?: MediaViewerOrigin;
isProtected?: boolean;
volume: number;
@@ -65,6 +66,7 @@ const MediaViewerContent = ({
isMoving,
onClose,
onFooterClick,
+ onSponsoredButtonClick,
}: OwnProps & StateProps) => {
const lang = useOldLang();
@@ -139,7 +141,7 @@ const MediaViewerContent = ({
const textParts = textMessage && (textMessage.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true }));
-
+ const buttonText = textMessage && 'buttonText' in textMessage ? textMessage.buttonText : undefined;
const hasFooter = Boolean(textParts);
const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);
const isForceMobileVersion = isMobile || shouldForceMobileVersion(posterSize);
@@ -185,10 +187,12 @@ const MediaViewerContent = ({
{textParts && (
)}
@@ -204,12 +208,14 @@ export default memo(withGlobal(
isHidden,
origin,
} = selectTabState(global).mediaViewer;
- const textMessage = item.type === 'message' ? item.message : undefined;
+ const message = item.type === 'message' ? item.message : undefined;
+ const sponsoredMessage = item.type === 'sponsoredMessage' ? item.message : undefined;
+ const textMessage = message || sponsoredMessage;
return {
origin,
textMessage,
- isProtected: textMessage && selectIsMessageProtected(global, textMessage),
+ isProtected: message && selectIsMessageProtected(global, message),
volume,
isMuted,
isHidden,
diff --git a/src/components/mediaViewer/MediaViewerFooter.scss b/src/components/mediaViewer/MediaViewerFooter.scss
index a370476b8..5e8182e1e 100644
--- a/src/components/mediaViewer/MediaViewerFooter.scss
+++ b/src/components/mediaViewer/MediaViewerFooter.scss
@@ -96,4 +96,8 @@
text-decoration: underline;
}
}
+
+ .media-viewer-button {
+ border-radius: 0.5rem;
+ }
}
diff --git a/src/components/mediaViewer/MediaViewerFooter.tsx b/src/components/mediaViewer/MediaViewerFooter.tsx
index 33ed55511..7d2a11ae6 100644
--- a/src/components/mediaViewer/MediaViewerFooter.tsx
+++ b/src/components/mediaViewer/MediaViewerFooter.tsx
@@ -12,20 +12,24 @@ import useAppLayout from '../../hooks/useAppLayout';
import useDerivedState from '../../hooks/useDerivedState';
import useControlsSignal from './hooks/useControlsSignal';
+import Button from '../ui/Button';
+
import './MediaViewerFooter.scss';
const RESIZE_THROTTLE_MS = 500;
type OwnProps = {
text: TextPart | TextPart[];
+ buttonText?: string;
onClick: () => void;
+ onButtonClick: () => void;
isForVideo: boolean;
isForceMobileVersion?: boolean;
isProtected?: boolean;
};
const MediaViewerFooter: FC = ({
- text = '', isForVideo, onClick, isProtected, isForceMobileVersion,
+ text = '', buttonText, isForVideo, onClick, onButtonClick, isProtected, isForceMobileVersion,
}) => {
const [isMultiline, setIsMultiline] = useState(false);
const { isMobile } = useAppLayout();
@@ -76,6 +80,17 @@ const MediaViewerFooter: FC = ({
)}
+ {Boolean(buttonText) && (
+
+ )}
);
};
diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx
index 2645d409a..7d739f898 100644
--- a/src/components/mediaViewer/MediaViewerSlides.tsx
+++ b/src/components/mediaViewer/MediaViewerSlides.tsx
@@ -56,6 +56,7 @@ type OwnProps = {
selectItem: (item: MediaViewerItem) => void;
loadMoreItemsIfNeeded: (item: MediaViewerItem) => void;
onFooterClick: () => void;
+ onSponsoredButtonClick: () => void;
onClose: () => void;
};
@@ -99,6 +100,7 @@ const MediaViewerSlides: FC = ({
selectItem,
onClose,
onFooterClick,
+ onSponsoredButtonClick,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef(null);
@@ -729,6 +731,7 @@ const MediaViewerSlides: FC = ({
item={prevItem}
onClose={onClose}
onFooterClick={onFooterClick}
+ onSponsoredButtonClick={onSponsoredButtonClick}
/>
)}
@@ -748,6 +751,7 @@ const MediaViewerSlides: FC = ({
isMoving={isMoving}
onClose={onClose}
onFooterClick={onFooterClick}
+ onSponsoredButtonClick={onSponsoredButtonClick}
/>
@@ -758,6 +762,7 @@ const MediaViewerSlides: FC = ({
item={nextItem}
onClose={onClose}
onFooterClick={onFooterClick}
+ onSponsoredButtonClick={onSponsoredButtonClick}
/>
)}
diff --git a/src/components/mediaViewer/helpers/getViewableMedia.ts b/src/components/mediaViewer/helpers/getViewableMedia.ts
index 84059264a..0c7ef02d2 100644
--- a/src/components/mediaViewer/helpers/getViewableMedia.ts
+++ b/src/components/mediaViewer/helpers/getViewableMedia.ts
@@ -1,4 +1,4 @@
-import type { ApiMessage, ApiPeer } from '../../../api/types';
+import type { ApiMessage, ApiPeer, ApiSponsoredMessage } from '../../../api/types';
import type { MediaViewerMedia } from '../../../types';
import { getMessageContent, isDocumentPhoto, isDocumentVideo } from '../../../global/helpers';
@@ -15,6 +15,10 @@ export type MediaViewerItem = {
type: 'standalone';
media: MediaViewerMedia[];
mediaIndex: number;
+} | {
+ type: 'sponsoredMessage';
+ message: ApiSponsoredMessage;
+ mediaIndex?: number;
};
type ViewableMedia = {
@@ -23,11 +27,12 @@ type ViewableMedia = {
};
export function getMediaViewerItem({
- message, avatarOwner, standaloneMedia, mediaIndex,
+ message, avatarOwner, standaloneMedia, mediaIndex, sponsoredMessage,
}: {
message?: ApiMessage;
avatarOwner?: ApiPeer;
standaloneMedia?: MediaViewerMedia[];
+ sponsoredMessage?: ApiSponsoredMessage;
mediaIndex?: number;
}): MediaViewerItem | undefined {
if (avatarOwner) {
@@ -54,6 +59,14 @@ export function getMediaViewerItem({
};
}
+ if (sponsoredMessage) {
+ return {
+ type: 'sponsoredMessage',
+ message: sponsoredMessage,
+ mediaIndex,
+ };
+ }
+
return undefined;
}
diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts
index 9e11960cb..f85bc6e0a 100644
--- a/src/components/mediaViewer/helpers/ghostAnimation.ts
+++ b/src/components/mediaViewer/helpers/ghostAnimation.ts
@@ -339,6 +339,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe
mediaSelector = index === 0 ? `.stars-transaction-media-${index} :is(img, video)` : undefined;
break;
+ case MediaViewerOrigin.SponsoredMessage:
+ containerSelector = '.Transition_slide-active > .MessageList .sponsored-media-preview';
+ mediaSelector = `${MESSAGE_CONTENT_SELECTOR} .full-media,${MESSAGE_CONTENT_SELECTOR} .thumbnail:not(.blurred-bg)`;
+ break;
+
case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.Inline:
default:
diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx
index ca380bc3b..2e51616ad 100644
--- a/src/components/middle/MessageListContent.tsx
+++ b/src/components/middle/MessageListContent.tsx
@@ -298,7 +298,13 @@ const MessageListContent: FC = ({
{shouldRenderBotInfo && }
{dateGroups.flat()}
{areAdsEnabled && isViewportNewest && (
-
+
)}
{withHistoryTriggers && (
;
+ observeIntersectionForLoading: ObserveFn;
+ observeIntersectionForPlaying: ObserveFn;
};
type StateProps = {
message?: ApiSponsoredMessage;
+ theme: ISettings['theme'];
+ isDownloading?: boolean;
+ canAutoLoadMedia?: boolean;
+ canAutoPlayMedia?: boolean;
};
const INTERSECTION_DEBOUNCE_MS = 200;
@@ -42,6 +65,12 @@ const SponsoredMessage: FC
= ({
chatId,
message,
containerRef,
+ theme,
+ observeIntersectionForLoading,
+ observeIntersectionForPlaying,
+ isDownloading,
+ canAutoLoadMedia,
+ canAutoPlayMedia,
}) => {
const {
viewSponsoredMessage,
@@ -49,6 +78,7 @@ const SponsoredMessage: FC = ({
hideSponsoredMessages,
clickSponsoredMessage,
reportSponsoredMessage,
+ openMediaViewer,
} = getActions();
const lang = useOldLang();
@@ -57,6 +87,8 @@ const SponsoredMessage: FC = ({
// eslint-disable-next-line no-null/no-null
const contentRef = useRef(null);
const shouldObserve = Boolean(message);
+
+ const { isMobile } = useAppLayout();
const {
observe: observeIntersection,
} = useIntersectionObserver({
@@ -99,7 +131,64 @@ const SponsoredMessage: FC = ({
openUrl({ url: message!.url, shouldSkipModal: true });
});
- if (!message) {
+ const handleOpenMedia = useLastCallback(() => {
+ openMediaViewer({
+ origin: MediaViewerOrigin.SponsoredMessage,
+ chatId,
+ isSponsoredMessage: true,
+ });
+ });
+
+ const {
+ photo, video,
+ } = message ? getMessageContent(message) : { photo: undefined, video: undefined };
+
+ const hasMedia = Boolean(photo || video);
+
+ const extraPadding = 0;
+
+ const sizeCalculations = useMemo(() => {
+ let calculatedWidth;
+ let contentWidth: number | undefined;
+ const noMediaCorners = false;
+ let style = '';
+
+ if (photo || video) {
+ let width: number | undefined;
+ if (photo) {
+ width = calculateMediaDimensions({
+ media: photo,
+ isMobile,
+ }).width;
+ } else if (video) {
+ width = calculateMediaDimensions({
+ media: video,
+ isMobile,
+ }).width;
+ }
+
+ if (width) {
+ if (width < MIN_MEDIA_WIDTH_WITH_TEXT) {
+ contentWidth = width;
+ }
+ calculatedWidth = Math.max(getMinMediaWidth(), width);
+ }
+ }
+
+ if (calculatedWidth) {
+ style = `width: ${calculatedWidth + extraPadding}px`;
+ }
+
+ return {
+ contentWidth, noMediaCorners, style,
+ };
+ }, [photo, video, isMobile]);
+
+ const {
+ contentWidth, style,
+ } = sizeCalculations;
+
+ if (!message || !message.content) {
return undefined;
}
@@ -108,14 +197,16 @@ const SponsoredMessage: FC = ({
return (
<>
{message.title}
-
-
- {renderTextWithEntities({
- text: message!.text.text,
- entities: message!.text.entities,
- })}
-
-
+ {Boolean(message.content?.text) && (
+
+
+ {renderTextWithEntities({
+ text: message.content.text.text,
+ entities: message.content.text.entities,
+ })}
+
+
+ )}