Sponsored Message: Implement media in ads (#4925)

This commit is contained in:
Alexander Zinchuk 2024-09-19 20:43:14 +02:00
parent 7d73682aa7
commit 505f67674a
22 changed files with 307 additions and 50 deletions

View File

@ -79,7 +79,9 @@ export function setMessageBuilderCurrentUserId(_currentUserId: string) {
currentUserId = _currentUserId; currentUserId = _currentUserId;
} }
export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): ApiSponsoredMessage | undefined { export function buildApiSponsoredMessage(
mtpMessage: GramJs.SponsoredMessage, chatId: string,
): ApiSponsoredMessage | undefined {
const { const {
message, entities, randomId, recommended, sponsorInfo, additionalInfo, buttonText, canReport, title, url, color, message, entities, randomId, recommended, sponsorInfo, additionalInfo, buttonText, canReport, title, url, color,
} = mtpMessage; } = mtpMessage;
@ -90,9 +92,14 @@ export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): A
photo = buildApiPhoto(mtpMessage.photo); photo = buildApiPhoto(mtpMessage.photo);
} }
let media: MediaContent | undefined;
if (mtpMessage.media) {
media = buildMessageMediaContent(mtpMessage.media);
}
return { return {
chatId,
randomId: serializeBytes(randomId), randomId: serializeBytes(randomId),
text: buildMessageTextContent(message, entities),
expiresAt: Math.round(Date.now() / 1000) + SPONSORED_MESSAGE_CACHE_MS, expiresAt: Math.round(Date.now() / 1000) + SPONSORED_MESSAGE_CACHE_MS,
isRecommended: recommended, isRecommended: recommended,
sponsorInfo, sponsorInfo,
@ -103,6 +110,10 @@ export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): A
url, url,
peerColor: color && buildApiPeerColor(color), peerColor: color && buildApiPeerColor(color),
photo, photo,
content: {
...media,
text: buildMessageTextContent(message, entities),
},
}; };
} }

View File

@ -1661,7 +1661,7 @@ export async function fetchSponsoredMessages({ chat }: { chat: ApiChat }) {
return undefined; return undefined;
} }
const messages = result.messages.map(buildApiSponsoredMessage).filter(Boolean); const messages = result.messages.map((message) => buildApiSponsoredMessage(message, chat.id)).filter(Boolean);
return { return {
messages, messages,

View File

@ -763,9 +763,9 @@ export type ApiThreadInfo = ApiCommentsInfo | ApiMessageThreadInfo;
export type ApiMessageOutgoingStatus = 'read' | 'succeeded' | 'pending' | 'failed'; export type ApiMessageOutgoingStatus = 'read' | 'succeeded' | 'pending' | 'failed';
export type ApiSponsoredMessage = { export type ApiSponsoredMessage = {
chatId: string;
randomId: string; randomId: string;
isRecommended?: true; isRecommended?: true;
text: ApiFormattedText;
expiresAt: number; expiresAt: number;
sponsorInfo?: string; sponsorInfo?: string;
additionalInfo?: string; additionalInfo?: string;
@ -774,6 +774,7 @@ export type ApiSponsoredMessage = {
title: string; title: string;
url: string; url: string;
photo?: ApiPhoto; photo?: ApiPhoto;
content: MediaContent;
peerColor?: ApiPeerColor; peerColor?: ApiPeerColor;
}; };

View File

@ -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 { LangFn } from '../../../hooks/useOldLang';
import type { TextPart } from '../../../types'; import type { TextPart } from '../../../types';
import { ApiMessageEntityTypes } from '../../../api/types'; import { ApiMessageEntityTypes } from '../../../api/types';
@ -28,7 +28,7 @@ export function renderMessageText({
shouldRenderAsHtml, shouldRenderAsHtml,
isForMediaViewer, isForMediaViewer,
} : { } : {
message: ApiMessage; message: ApiMessage | ApiSponsoredMessage;
highlight?: string; highlight?: string;
emojiSize?: number; emojiSize?: number;
isSimple?: boolean; isSimple?: boolean;

View File

@ -5,7 +5,7 @@ import { getActions, withGlobal } from '../../global';
import type { import type {
ApiChat, ApiChat,
ApiMessage, ApiPeer, ApiPhoto, ApiMessage, ApiPeer, ApiPhoto, ApiSponsoredMessage,
} from '../../api/types'; } from '../../api/types';
import { type MediaViewerMedia, MediaViewerOrigin, type ThreadId } from '../../types'; import { type MediaViewerMedia, MediaViewerOrigin, type ThreadId } from '../../types';
@ -25,7 +25,7 @@ import {
selectOutlyingListByMessageId, selectOutlyingListByMessageId,
selectPeer, selectPeer,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectScheduledMessage, selectScheduledMessage, selectSponsoredMessage,
selectTabState, selectTabState,
} from '../../global/selectors'; } from '../../global/selectors';
import { stopCurrentAudio } from '../../util/audioPlayer'; import { stopCurrentAudio } from '../../util/audioPlayer';
@ -70,6 +70,7 @@ type StateProps = {
avatar?: ApiPhoto; avatar?: ApiPhoto;
avatarOwner?: ApiPeer; avatarOwner?: ApiPeer;
chatMessages?: Record<number, ApiMessage>; chatMessages?: Record<number, ApiMessage>;
sponsoredMessage?: ApiSponsoredMessage;
standaloneMedia?: MediaViewerMedia[]; standaloneMedia?: MediaViewerMedia[];
mediaIndex?: number; mediaIndex?: number;
isHidden?: boolean; isHidden?: boolean;
@ -95,6 +96,7 @@ const MediaViewer = ({
avatar, avatar,
avatarOwner, avatarOwner,
chatMessages, chatMessages,
sponsoredMessage,
standaloneMedia, standaloneMedia,
mediaIndex, mediaIndex,
withAnimation, withAnimation,
@ -112,9 +114,11 @@ const MediaViewer = ({
toggleChatInfo, toggleChatInfo,
searchChatMediaMessages, searchChatMediaMessages,
loadMoreProfilePhotos, loadMoreProfilePhotos,
clickSponsoredMessage,
openUrl,
} = getActions(); } = getActions();
const isOpen = Boolean(avatarOwner || message || standaloneMedia); const isOpen = Boolean(avatarOwner || message || standaloneMedia || sponsoredMessage);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
/* Animation */ /* Animation */
@ -128,7 +132,7 @@ const MediaViewer = ({
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
const currentItem = getMediaViewerItem({ const currentItem = getMediaViewerItem({
message, avatarOwner, standaloneMedia, mediaIndex, message, avatarOwner, standaloneMedia, mediaIndex, sponsoredMessage,
}); });
const { media, isSingle } = getViewableMedia(currentItem) || {}; 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(() => { const handleForward = useLastCallback(() => {
openForwardMenu({ openForwardMenu({
fromChatId: chatId!, fromChatId: chatId!,
@ -296,6 +308,16 @@ const MediaViewer = ({
return undefined; 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 { message: fromMessage, mediaIndex: fromMediaIndex } = from;
const paidMedia = getMessagePaidMedia(fromMessage); const paidMedia = getMessagePaidMedia(fromMessage);
@ -434,6 +456,7 @@ const MediaViewer = ({
selectItem={openMediaViewerItem} selectItem={openMediaViewerItem}
isHidden={isHidden} isHidden={isHidden}
onFooterClick={handleFooterClick} onFooterClick={handleFooterClick}
onSponsoredButtonClick={onSponsoredButtonClick}
/> />
</ShowTransition> </ShowTransition>
); );
@ -452,6 +475,7 @@ export default memo(withGlobal(
standaloneMedia, standaloneMedia,
mediaIndex, mediaIndex,
isAvatarView, isAvatarView,
isSponsoredMessage,
} = mediaViewer; } = mediaViewer;
const withAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations'); 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<number, ApiMessage> | undefined; let chatMessages: Record<number, ApiMessage> | undefined;
if (chatId) { if (chatId) {
@ -531,6 +562,7 @@ export default memo(withGlobal(
origin, origin,
message, message,
chatMessages, chatMessages,
sponsoredMessage,
collectedMessageIds, collectedMessageIds,
withAnimation, withAnimation,
isHidden, isHidden,

View File

@ -180,7 +180,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
return undefined; return undefined;
} }
return isVideo ? ( return item?.type !== 'sponsoredMessage' && (isVideo ? (
<Button <Button
round round
size="smaller" size="smaller"
@ -205,7 +205,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
> >
<i className="icon icon-download" /> <i className="icon icon-download" />
</Button> </Button>
); ));
} }
const openDeleteModalHandler = useLastCallback(() => { const openDeleteModalHandler = useLastCallback(() => {

View File

@ -2,7 +2,7 @@ import React, { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { import type {
ApiDimensions, ApiMessage, ApiDimensions, ApiMessage, ApiSponsoredMessage,
} from '../../api/types'; } from '../../api/types';
import type { MediaViewerOrigin } from '../../types'; import type { MediaViewerOrigin } from '../../types';
import type { MediaViewerItem } from './helpers/getViewableMedia'; import type { MediaViewerItem } from './helpers/getViewableMedia';
@ -36,10 +36,11 @@ type OwnProps = {
isMoving?: boolean; isMoving?: boolean;
onClose: () => void; onClose: () => void;
onFooterClick: () => void; onFooterClick: () => void;
onSponsoredButtonClick: () => void;
}; };
type StateProps = { type StateProps = {
textMessage?: ApiMessage; textMessage?: ApiMessage | ApiSponsoredMessage;
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
isProtected?: boolean; isProtected?: boolean;
volume: number; volume: number;
@ -65,6 +66,7 @@ const MediaViewerContent = ({
isMoving, isMoving,
onClose, onClose,
onFooterClick, onFooterClick,
onSponsoredButtonClick,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const lang = useOldLang(); const lang = useOldLang();
@ -139,7 +141,7 @@ const MediaViewerContent = ({
const textParts = textMessage && (textMessage.content.action?.type === 'suggestProfilePhoto' const textParts = textMessage && (textMessage.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle') ? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true })); : renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true }));
const buttonText = textMessage && 'buttonText' in textMessage ? textMessage.buttonText : undefined;
const hasFooter = Boolean(textParts); const hasFooter = Boolean(textParts);
const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo); const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);
const isForceMobileVersion = isMobile || shouldForceMobileVersion(posterSize); const isForceMobileVersion = isMobile || shouldForceMobileVersion(posterSize);
@ -185,10 +187,12 @@ const MediaViewerContent = ({
{textParts && ( {textParts && (
<MediaViewerFooter <MediaViewerFooter
text={textParts} text={textParts}
buttonText={buttonText}
onClick={onFooterClick} onClick={onFooterClick}
isProtected={isProtected} isProtected={isProtected}
isForceMobileVersion={isForceMobileVersion} isForceMobileVersion={isForceMobileVersion}
isForVideo={isVideo && !isGif} isForVideo={isVideo && !isGif}
onButtonClick={onSponsoredButtonClick}
/> />
)} )}
</div> </div>
@ -204,12 +208,14 @@ export default memo(withGlobal<OwnProps>(
isHidden, isHidden,
origin, origin,
} = selectTabState(global).mediaViewer; } = 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 { return {
origin, origin,
textMessage, textMessage,
isProtected: textMessage && selectIsMessageProtected(global, textMessage), isProtected: message && selectIsMessageProtected(global, message),
volume, volume,
isMuted, isMuted,
isHidden, isHidden,

View File

@ -96,4 +96,8 @@
text-decoration: underline; text-decoration: underline;
} }
} }
.media-viewer-button {
border-radius: 0.5rem;
}
} }

View File

@ -12,20 +12,24 @@ import useAppLayout from '../../hooks/useAppLayout';
import useDerivedState from '../../hooks/useDerivedState'; import useDerivedState from '../../hooks/useDerivedState';
import useControlsSignal from './hooks/useControlsSignal'; import useControlsSignal from './hooks/useControlsSignal';
import Button from '../ui/Button';
import './MediaViewerFooter.scss'; import './MediaViewerFooter.scss';
const RESIZE_THROTTLE_MS = 500; const RESIZE_THROTTLE_MS = 500;
type OwnProps = { type OwnProps = {
text: TextPart | TextPart[]; text: TextPart | TextPart[];
buttonText?: string;
onClick: () => void; onClick: () => void;
onButtonClick: () => void;
isForVideo: boolean; isForVideo: boolean;
isForceMobileVersion?: boolean; isForceMobileVersion?: boolean;
isProtected?: boolean; isProtected?: boolean;
}; };
const MediaViewerFooter: FC<OwnProps> = ({ const MediaViewerFooter: FC<OwnProps> = ({
text = '', isForVideo, onClick, isProtected, isForceMobileVersion, text = '', buttonText, isForVideo, onClick, onButtonClick, isProtected, isForceMobileVersion,
}) => { }) => {
const [isMultiline, setIsMultiline] = useState(false); const [isMultiline, setIsMultiline] = useState(false);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
@ -76,6 +80,17 @@ const MediaViewerFooter: FC<OwnProps> = ({
</p> </p>
</div> </div>
)} )}
{Boolean(buttonText) && (
<Button
className={buildClassName('media-viewer-footer-content', 'media-viewer-button')}
size="default"
color="translucent"
isRectangular
onClick={onButtonClick}
>
{buttonText}
</Button>
)}
</div> </div>
); );
}; };

View File

@ -56,6 +56,7 @@ type OwnProps = {
selectItem: (item: MediaViewerItem) => void; selectItem: (item: MediaViewerItem) => void;
loadMoreItemsIfNeeded: (item: MediaViewerItem) => void; loadMoreItemsIfNeeded: (item: MediaViewerItem) => void;
onFooterClick: () => void; onFooterClick: () => void;
onSponsoredButtonClick: () => void;
onClose: () => void; onClose: () => void;
}; };
@ -99,6 +100,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
selectItem, selectItem,
onClose, onClose,
onFooterClick, onFooterClick,
onSponsoredButtonClick,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -729,6 +731,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
item={prevItem} item={prevItem}
onClose={onClose} onClose={onClose}
onFooterClick={onFooterClick} onFooterClick={onFooterClick}
onSponsoredButtonClick={onSponsoredButtonClick}
/> />
)} )}
</div> </div>
@ -748,6 +751,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
isMoving={isMoving} isMoving={isMoving}
onClose={onClose} onClose={onClose}
onFooterClick={onFooterClick} onFooterClick={onFooterClick}
onSponsoredButtonClick={onSponsoredButtonClick}
/> />
</div> </div>
<div className="MediaViewerSlide" ref={rightSlideRef}> <div className="MediaViewerSlide" ref={rightSlideRef}>
@ -758,6 +762,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
item={nextItem} item={nextItem}
onClose={onClose} onClose={onClose}
onFooterClick={onFooterClick} onFooterClick={onFooterClick}
onSponsoredButtonClick={onSponsoredButtonClick}
/> />
)} )}
</div> </div>

View File

@ -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 type { MediaViewerMedia } from '../../../types';
import { getMessageContent, isDocumentPhoto, isDocumentVideo } from '../../../global/helpers'; import { getMessageContent, isDocumentPhoto, isDocumentVideo } from '../../../global/helpers';
@ -15,6 +15,10 @@ export type MediaViewerItem = {
type: 'standalone'; type: 'standalone';
media: MediaViewerMedia[]; media: MediaViewerMedia[];
mediaIndex: number; mediaIndex: number;
} | {
type: 'sponsoredMessage';
message: ApiSponsoredMessage;
mediaIndex?: number;
}; };
type ViewableMedia = { type ViewableMedia = {
@ -23,11 +27,12 @@ type ViewableMedia = {
}; };
export function getMediaViewerItem({ export function getMediaViewerItem({
message, avatarOwner, standaloneMedia, mediaIndex, message, avatarOwner, standaloneMedia, mediaIndex, sponsoredMessage,
}: { }: {
message?: ApiMessage; message?: ApiMessage;
avatarOwner?: ApiPeer; avatarOwner?: ApiPeer;
standaloneMedia?: MediaViewerMedia[]; standaloneMedia?: MediaViewerMedia[];
sponsoredMessage?: ApiSponsoredMessage;
mediaIndex?: number; mediaIndex?: number;
}): MediaViewerItem | undefined { }): MediaViewerItem | undefined {
if (avatarOwner) { if (avatarOwner) {
@ -54,6 +59,14 @@ export function getMediaViewerItem({
}; };
} }
if (sponsoredMessage) {
return {
type: 'sponsoredMessage',
message: sponsoredMessage,
mediaIndex,
};
}
return undefined; return undefined;
} }

View File

@ -339,6 +339,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe
mediaSelector = index === 0 ? `.stars-transaction-media-${index} :is(img, video)` : undefined; mediaSelector = index === 0 ? `.stars-transaction-media-${index} :is(img, video)` : undefined;
break; 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.ScheduledInline:
case MediaViewerOrigin.Inline: case MediaViewerOrigin.Inline:
default: default:

View File

@ -298,7 +298,13 @@ const MessageListContent: FC<OwnProps> = ({
{shouldRenderBotInfo && <MessageListBotInfo isInMessageList key={`bot_info_${chatId}`} chatId={chatId} />} {shouldRenderBotInfo && <MessageListBotInfo isInMessageList key={`bot_info_${chatId}`} chatId={chatId} />}
{dateGroups.flat()} {dateGroups.flat()}
{areAdsEnabled && isViewportNewest && ( {areAdsEnabled && isViewportNewest && (
<SponsoredMessage key={chatId} chatId={chatId} containerRef={containerRef} /> <SponsoredMessage
key={chatId}
chatId={chatId}
containerRef={containerRef}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
)} )}
{withHistoryTriggers && ( {withHistoryTriggers && (
<div <div

View File

@ -1,3 +1,5 @@
@use "message-content";
.SponsoredMessage { .SponsoredMessage {
--border-top-left-radius: var(--border-radius-messages) !important; --border-top-left-radius: var(--border-radius-messages) !important;
--border-bottom-left-radius: 0 !important; --border-bottom-left-radius: 0 !important;
@ -44,6 +46,10 @@
.message-content { .message-content {
padding: 0.5rem; padding: 0.5rem;
--border-top-left-radius: var(--border-radius-messages-small);
--border-top-right-radius: var(--border-radius-messages-small);
--border-bottom-right-radius: var(--border-radius-messages-small);
--border-bottom-left-radius: var(--border-radius-messages-small);
@media (max-width: 600px) { @media (max-width: 600px) {
max-width: min(29rem, calc(100vw - 4.5rem)) !important; max-width: min(29rem, calc(100vw - 4.5rem)) !important;
@ -129,4 +135,8 @@
filter: opacity(1); filter: opacity(1);
} }
} }
.has-media {
padding-top: 0.5rem;
}
} }

View File

@ -1,19 +1,34 @@
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; import React, {
memo, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiSponsoredMessage } from '../../../api/types'; import type { ApiSponsoredMessage } from '../../../api/types';
import type { ISettings } from '../../../types';
import { MediaViewerOrigin } from '../../../types';
import { selectSponsoredMessage } from '../../../global/selectors'; import {
getIsDownloading,
getMessageContent,
getMessageDownloadableMedia,
} from '../../../global/helpers';
import {
selectActiveDownloads, selectCanAutoLoadMedia, selectCanAutoPlayMedia,
selectSponsoredMessage,
selectTheme,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { IS_ANDROID } from '../../../util/windowEnvironment'; import { IS_ANDROID } from '../../../util/windowEnvironment';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur'; import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur';
import { calculateMediaDimensions, getMinMediaWidth, MIN_MEDIA_WIDTH_WITH_TEXT } from './helpers/mediaDimensions';
import useAppLayout from '../../../hooks/useAppLayout';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import { type ObserveFn, useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
@ -23,17 +38,25 @@ import Icon from '../../common/icons/Icon';
import PeerColorWrapper from '../../common/PeerColorWrapper'; import PeerColorWrapper from '../../common/PeerColorWrapper';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import MessageAppendix from './MessageAppendix'; import MessageAppendix from './MessageAppendix';
import Photo from './Photo';
import SponsoredMessageContextMenuContainer from './SponsoredMessageContextMenuContainer.async'; import SponsoredMessageContextMenuContainer from './SponsoredMessageContextMenuContainer.async';
import Video from './Video';
import './SponsoredMessage.scss'; import './SponsoredMessage.scss';
type OwnProps = { type OwnProps = {
chatId: string; chatId: string;
containerRef: RefObject<HTMLDivElement>; containerRef: RefObject<HTMLDivElement>;
observeIntersectionForLoading: ObserveFn;
observeIntersectionForPlaying: ObserveFn;
}; };
type StateProps = { type StateProps = {
message?: ApiSponsoredMessage; message?: ApiSponsoredMessage;
theme: ISettings['theme'];
isDownloading?: boolean;
canAutoLoadMedia?: boolean;
canAutoPlayMedia?: boolean;
}; };
const INTERSECTION_DEBOUNCE_MS = 200; const INTERSECTION_DEBOUNCE_MS = 200;
@ -42,6 +65,12 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
chatId, chatId,
message, message,
containerRef, containerRef,
theme,
observeIntersectionForLoading,
observeIntersectionForPlaying,
isDownloading,
canAutoLoadMedia,
canAutoPlayMedia,
}) => { }) => {
const { const {
viewSponsoredMessage, viewSponsoredMessage,
@ -49,6 +78,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
hideSponsoredMessages, hideSponsoredMessages,
clickSponsoredMessage, clickSponsoredMessage,
reportSponsoredMessage, reportSponsoredMessage,
openMediaViewer,
} = getActions(); } = getActions();
const lang = useOldLang(); const lang = useOldLang();
@ -57,6 +87,8 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const shouldObserve = Boolean(message); const shouldObserve = Boolean(message);
const { isMobile } = useAppLayout();
const { const {
observe: observeIntersection, observe: observeIntersection,
} = useIntersectionObserver({ } = useIntersectionObserver({
@ -99,7 +131,64 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
openUrl({ url: message!.url, shouldSkipModal: true }); 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; return undefined;
} }
@ -108,14 +197,16 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
return ( return (
<> <>
<div className="message-title message-peer" dir="auto">{message.title}</div> <div className="message-title message-peer" dir="auto">{message.title}</div>
<div className="text-content with-meta" dir="auto" ref={contentRef}> {Boolean(message.content?.text) && (
<span className="text-content-inner" dir="auto"> <div className="text-content with-meta" dir="auto" ref={contentRef}>
{renderTextWithEntities({ <span className="text-content-inner" dir="auto">
text: message!.text.text, {renderTextWithEntities({
entities: message!.text.entities, text: message.content.text.text,
})} entities: message.content.text.entities,
</span> })}
</div> </span>
</div>
)}
<Button <Button
className="SponsoredMessage__button" className="SponsoredMessage__button"
@ -130,19 +221,57 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
); );
} }
function renderMediaContent() {
if (!message) return undefined;
if (photo) {
return (
<Photo
photo={photo}
theme={theme}
canAutoLoad={canAutoLoadMedia}
isDownloading={isDownloading}
observeIntersection={observeIntersectionForLoading}
noAvatars
onClick={handleOpenMedia}
forcedWidth={contentWidth}
/>
);
}
if (video) {
return (
<Video
video={video}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
noAvatars
canAutoLoad={canAutoLoadMedia}
canAutoPlay={canAutoPlayMedia}
isDownloading={isDownloading}
onClick={handleOpenMedia}
forcedWidth={contentWidth}
/>
);
}
return undefined;
}
return ( return (
<div <div
ref={ref} ref={ref}
style={style}
key="sponsored-message" key="sponsored-message"
className="SponsoredMessage Message open" className="SponsoredMessage Message open sponsored-media-preview"
> >
<div <div
className="message-content has-shadow has-solid-background has-appendix" className="message-content media has-shadow has-solid-background has-appendix"
dir="auto" dir="auto"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
<PeerColorWrapper peerColor={message.peerColor} className="content-inner" dir="auto"> <PeerColorWrapper peerColor={message.peerColor} className="content-inner" dir="auto">
{renderMediaContent()}
{message.photo && ( {message.photo && (
<Avatar <Avatar
size="large" size="large"
@ -150,7 +279,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
className={buildClassName('channel-avatar', lang.isRtl && 'is-rtl')} className={buildClassName('channel-avatar', lang.isRtl && 'is-rtl')}
/> />
)} )}
<span className="message-title message-type"> <span className={buildClassName('message-title message-type', hasMedia && 'has-media')}>
{message!.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')} {message!.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')}
<span onClick={openAboutAdsModal} className="ad-about">{lang('SponsoredMessageAdWhatIsThis')}</span> <span onClick={openAboutAdsModal} className="ad-about">{lang('SponsoredMessageAdWhatIsThis')}</span>
</span> </span>
@ -207,8 +336,16 @@ export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => { (global, { chatId }): StateProps => {
const message = selectSponsoredMessage(global, chatId); const message = selectSponsoredMessage(global, chatId);
const activeDownloads = selectActiveDownloads(global);
const downloadableMedia = message ? getMessageDownloadableMedia(message) : undefined;
const isDownloading = downloadableMedia && getIsDownloading(activeDownloads, downloadableMedia);
return { return {
message, message,
theme: selectTheme(global),
isDownloading,
canAutoLoadMedia: message ? selectCanAutoLoadMedia(global, message) : undefined,
canAutoPlayMedia: message ? selectCanAutoPlayMedia(global, message) : undefined,
}; };
}, },
)(SponsoredMessage)); )(SponsoredMessage));

View File

@ -8,7 +8,7 @@ import { selectTabState } from '../../selectors';
addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType => { addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType => {
const { const {
chatId, threadId = MAIN_THREAD_ID, messageId, mediaIndex, isAvatarView, origin, chatId, threadId = MAIN_THREAD_ID, messageId, mediaIndex, isAvatarView, isSponsoredMessage, origin,
withDynamicLoading, standaloneMedia, tabId = getCurrentTabId(), withDynamicLoading, standaloneMedia, tabId = getCurrentTabId(),
} = payload; } = payload;
@ -22,6 +22,7 @@ addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType
messageId, messageId,
mediaIndex: mediaIndex || 0, mediaIndex: mediaIndex || 0,
isAvatarView, isAvatarView,
isSponsoredMessage,
origin, origin,
standaloneMedia, standaloneMedia,
isHidden: false, isHidden: false,

View File

@ -2,7 +2,7 @@ import type {
ApiAttachment, ApiAttachment,
ApiMessage, ApiMessage,
ApiMessageEntityTextUrl, ApiMessageEntityTextUrl,
ApiPeer, ApiPeer, ApiSponsoredMessage,
ApiStory, ApiStory,
} from '../../api/types'; } from '../../api/types';
import type { MediaContent } from '../../api/types/messages'; import type { MediaContent } from '../../api/types/messages';
@ -52,7 +52,7 @@ export function getMessageTranscription(message: ApiMessage) {
return transcriptionId && global.transcriptions[transcriptionId]?.text; return transcriptionId && global.transcriptions[transcriptionId]?.text;
} }
export function hasMessageText(message: ApiMessage | ApiStory) { export function hasMessageText(message: ApiMessage | ApiStory | ApiSponsoredMessage) {
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, action, storyData, giveaway, giveawayResults, isExpiredVoice, paidMedia, game, action, storyData, giveaway, giveawayResults, isExpiredVoice, paidMedia,
@ -64,7 +64,7 @@ export function hasMessageText(message: ApiMessage | ApiStory) {
); );
} }
export function getMessageText(message: ApiMessage | ApiStory) { export function getMessageText(message: ApiMessage | ApiStory | ApiSponsoredMessage) {
return hasMessageText(message) ? message.content.text?.text || CONTENT_NOT_SUPPORTED : undefined; return hasMessageText(message) ? message.content.text?.text || CONTENT_NOT_SUPPORTED : undefined;
} }

View File

@ -5,7 +5,7 @@ import type {
ApiMessageEntityCustomEmoji, ApiMessageEntityCustomEmoji,
ApiMessageForwardInfo, ApiMessageForwardInfo,
ApiMessageOutgoingStatus, ApiMessageOutgoingStatus,
ApiPeer, ApiPeer, ApiSponsoredMessage,
ApiStickerSetInfo, ApiStickerSetInfo,
} from '../../api/types'; } from '../../api/types';
import type { ThreadId } from '../../types'; import type { ThreadId } from '../../types';
@ -1101,13 +1101,15 @@ function selectShouldDisplayReplyKeyboard<T extends GlobalState>(global: T, mess
return true; return true;
} }
export function selectCanAutoLoadMedia<T extends GlobalState>(global: T, message: ApiMessage) { export function selectCanAutoLoadMedia<T extends GlobalState>(
global: T, message: ApiMessage | ApiSponsoredMessage,
) {
const chat = selectChat(global, message.chatId); const chat = selectChat(global, message.chatId);
if (!chat) { if (!chat) {
return undefined; return undefined;
} }
const sender = selectSender(global, message); const sender = 'id' in message ? selectSender(global, message) : undefined;
const isPhoto = Boolean(getMessagePhoto(message) || getMessageWebPagePhoto(message)); const isPhoto = Boolean(getMessagePhoto(message) || getMessageWebPagePhoto(message));
const isVideo = Boolean(getMessageVideo(message) || getMessageWebPageVideo(message)); const isVideo = Boolean(getMessageVideo(message) || getMessageWebPageVideo(message));

View File

@ -1,4 +1,4 @@
import type { ApiMessage } from '../../api/types'; import type { ApiMessage, ApiSponsoredMessage } from '../../api/types';
import type { PerformanceTypeKey } from '../../types'; import type { PerformanceTypeKey } from '../../types';
import type { GlobalState, TabArgs } from '../types'; import type { GlobalState, TabArgs } from '../types';
import { NewChatMembersProgress, RightColumnContent } from '../../types'; import { NewChatMembersProgress, RightColumnContent } from '../../types';
@ -19,9 +19,10 @@ export function selectIsMediaViewerOpen<T extends GlobalState>(
messageId, messageId,
isAvatarView, isAvatarView,
standaloneMedia, standaloneMedia,
isSponsoredMessage,
}, },
} = selectTabState(global, tabId); } = selectTabState(global, tabId);
return Boolean(standaloneMedia || (chatId && (isAvatarView || messageId))); return Boolean(standaloneMedia || (chatId && (isAvatarView || messageId || isSponsoredMessage)));
} }
export function selectRightColumnContentKey<T extends GlobalState>( export function selectRightColumnContentKey<T extends GlobalState>(
@ -111,7 +112,7 @@ export function selectPerformanceSettingsValue<T extends GlobalState>(
return global.settings.performance[key]; return global.settings.performance[key];
} }
export function selectCanAutoPlayMedia<T extends GlobalState>(global: T, message: ApiMessage) { export function selectCanAutoPlayMedia<T extends GlobalState>(global: T, message: ApiMessage | ApiSponsoredMessage) {
const video = getMessageVideo(message) || getMessageWebPageVideo(message); const video = getMessageVideo(message) || getMessageWebPageVideo(message);
if (!video) { if (!video) {
return undefined; return undefined;

View File

@ -469,6 +469,7 @@ export type TabState = {
withDynamicLoading?: boolean; withDynamicLoading?: boolean;
mediaIndex?: number; mediaIndex?: number;
isAvatarView?: boolean; isAvatarView?: boolean;
isSponsoredMessage?: boolean;
standaloneMedia?: MediaViewerMedia[]; standaloneMedia?: MediaViewerMedia[];
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
volume: number; volume: number;
@ -2570,6 +2571,7 @@ export interface ActionPayloads {
standaloneMedia?: MediaViewerMedia[]; standaloneMedia?: MediaViewerMedia[];
mediaIndex?: number; mediaIndex?: number;
isAvatarView?: boolean; isAvatarView?: boolean;
isSponsoredMessage?: boolean;
origin: MediaViewerOrigin; origin: MediaViewerOrigin;
withDynamicLoading?: boolean; withDynamicLoading?: boolean;
} & WithTabId; } & WithTabId;

View File

@ -330,6 +330,7 @@ export enum MediaViewerOrigin {
SuggestedAvatar, SuggestedAvatar,
StarsTransaction, StarsTransaction,
PreviewMedia, PreviewMedia,
SponsoredMessage,
} }
export enum StoryViewerOrigin { export enum StoryViewerOrigin {

View File

@ -1,11 +1,16 @@
import type { ApiMessage } from '../../api/types'; import type { ApiMessage, ApiSponsoredMessage } from '../../api/types';
export type MessageKey = `msg${string}-${number}`; export type MessageKey = `msg${string}-${number}`;
export function getMessageKey(message: ApiMessage): MessageKey { export function getMessageKey(message: ApiMessage | ApiSponsoredMessage): MessageKey {
const { chatId, id, previousLocalId } = message; const {
chatId,
} = message;
return buildMessageKey(chatId, previousLocalId || id); if ('randomId' in message) {
return buildMessageKey(chatId, Number(message.randomId));
}
return buildMessageKey(chatId, message.previousLocalId || message.id);
} }
export function getMessageServerKey(message: ApiMessage): MessageKey | undefined { export function getMessageServerKey(message: ApiMessage): MessageKey | undefined {