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, + })} + +
+ )}