Middle Header: Support bot ads (#5220)

This commit is contained in:
zubiden 2024-11-27 20:34:08 +04:00 committed by Alexander Zinchuk
parent 5a647a0a83
commit 228cd56091
30 changed files with 677 additions and 265 deletions

View File

@ -704,7 +704,8 @@
"Answer_other" = "{count} answers";
"Vote" = "Vote";
"MessageRecommendedLabel" = "recommended";
"SponsoredMessage" = "ad";
"SponsoredMessageAd" = "Ad";
"SponsoredMessageAdWhatIsThis" = "what's this?";
"PremiumStickerTooltip" = "This set contains premium stickers like this one.";
"ViewAction" = "View";
"Loading" = "Loading...";

View File

@ -25,7 +25,7 @@ export { default as GiftCodeModal } from '../components/modals/giftcode/GiftCode
export { default as ChatlistModal } from '../components/modals/chatlist/ChatlistModal';
export { default as ChatInviteModal } from '../components/modals/chatInvite/ChatInviteModal';
export { default as AboutAdsModal } from '../components/common/AboutAdsModal';
export { default as AboutAdsModal } from '../components/modals/aboutAds/AboutAdsModal';
export { default as AboutMonetizationModal } from '../components/common/AboutMonetizationModal';
export { default as VerificationMonetizationModal } from '../components/common/VerificationMonetizationModal';
export { default as ReportAdModal } from '../components/modals/reportAd/ReportAdModal';

View File

@ -1,127 +0,0 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useMemo } from '../../lib/teact/teact';
import type { TableAboutData } from '../modals/common/TableAboutModal';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
import useSelectorSignal from '../../hooks/data/useSelectorSignal';
import useDerivedState from '../../hooks/useDerivedState';
import useOldLang from '../../hooks/useOldLang';
import TableAboutModal from '../modals/common/TableAboutModal';
import Button from '../ui/Button';
import Modal from '../ui/Modal';
import SafeLink from './SafeLink';
import styles from './AboutAdsModal.module.scss';
export type OwnProps = {
isOpen: boolean;
isMonetizationSharing?: boolean;
onClose: NoneToVoidFunction;
};
const AboutAdsModal: FC<OwnProps> = ({
isOpen,
isMonetizationSharing,
onClose,
}) => {
const oldLang = useOldLang();
const minLevelSignal = useSelectorSignal((global) => global.appConfig?.channelRestrictAdsLevelMin);
const minLevelToRestrictAds = useDerivedState(minLevelSignal);
const regularAdContent = useMemo(() => {
return (
<>
<h3>{oldLang('SponsoredMessageInfoScreen.Title')}</h3>
<p>{renderText(oldLang('SponsoredMessageInfoDescription1'), ['br'])}</p>
<p>{renderText(oldLang('SponsoredMessageInfoDescription2'), ['br'])}</p>
<p>{renderText(oldLang('SponsoredMessageInfoDescription3'), ['br'])}</p>
<p>
<SafeLink
url={oldLang('SponsoredMessageAlertLearnMoreUrl')}
text={oldLang('SponsoredMessageAlertLearnMoreUrl')}
/>
</p>
<p>{renderText(oldLang('SponsoredMessageInfoDescription4'), ['br'])}</p>
</>
);
}, [oldLang]);
const modalData = useMemo(() => {
if (!isOpen) return undefined;
const header = (
<>
<h3 className={styles.title}>{oldLang('AboutRevenueSharingAds')}</h3>
<p className={buildClassName(styles.description, styles.secondary)}>
{oldLang('RevenueSharingAdsAlertSubtitle')}
</p>
</>
);
const listItemData = [
['lock', oldLang('RevenueSharingAdsInfo1Title'),
renderText(oldLang('RevenueSharingAdsInfo1Subtitle'), ['simple_markdown'])],
['revenue-split', oldLang('RevenueSharingAdsInfo2Title'),
renderText(oldLang('RevenueSharingAdsInfo2Subtitle'), ['simple_markdown'])],
['nochannel', oldLang('RevenueSharingAdsInfo3Title'),
renderText(oldLang('RevenueSharingAdsInfo3Subtitle', minLevelToRestrictAds), ['simple_markdown'])],
] satisfies TableAboutData;
const footer = (
<>
<h3 className={styles.title}>{renderText(oldLang('RevenueSharingAdsInfo4Title'), ['simple_markdown'])}</h3>
<p className={styles.description}>
{renderText(oldLang('RevenueSharingAdsInfo4Subtitle2', ''), ['simple_markdown'])}
<SafeLink
url={oldLang('PromoteUrl')}
text={oldLang('LearnMoreArrow')}
/>
</p>
</>
);
return {
header,
listItemData,
footer,
};
}, [isOpen, oldLang, minLevelToRestrictAds]);
if (isMonetizationSharing && modalData) {
return (
<TableAboutModal
isOpen={isOpen}
listItemData={modalData.listItemData}
headerIconName="channel"
header={modalData.header}
footer={modalData.footer}
buttonText={oldLang('RevenueSharingAdsUnderstood')}
onClose={onClose}
/>
);
}
return (
<Modal
isOpen={isOpen}
className={styles.root}
contentClassName={styles.content}
onClose={onClose}
>
{regularAdContent}
<Button
size="smaller"
onClick={onClose}
>
{oldLang('RevenueSharingAdsUnderstood')}
</Button>
</Modal>
);
};
export default memo(AboutAdsModal);

View File

@ -7,7 +7,7 @@ import styles from './BadgeButton.module.scss';
type OwnProps = {
children: React.ReactNode;
className?: string;
onClick?: NoneToVoidFunction;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
};
const BadgeButton = ({

View File

@ -227,10 +227,11 @@ const MessageList: FC<OwnProps & StateProps> = ({
}, [firstUnreadId]);
useEffect(() => {
if (areAdsEnabled && isChannelChat && isSynced && isReady) {
const canHaveAds = isChannelChat || isBot;
if (areAdsEnabled && canHaveAds && isSynced && isReady) {
loadSponsoredMessages({ peerId: chatId });
}
}, [chatId, isSynced, isReady, isChannelChat, areAdsEnabled]);
}, [chatId, isSynced, isReady, isChannelChat, isBot, areAdsEnabled]);
// Updated only once when messages are loaded (as we want the unread divider to keep its position)
useSyncEffect(() => {
@ -695,7 +696,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
/>
) : hasMessages ? (
<MessageListContent
areAdsEnabled={areAdsEnabled}
canShowAds={areAdsEnabled && isChannelChat}
chatId={chatId}
isComments={isComments}
isChannelChat={isChannelChat}

View File

@ -36,7 +36,7 @@ import SponsoredMessage from './message/SponsoredMessage';
import MessageListBotInfo from './MessageListBotInfo';
interface OwnProps {
areAdsEnabled?: boolean;
canShowAds?: boolean;
chatId: string;
threadId: ThreadId;
messageIds: number[];
@ -68,7 +68,7 @@ interface OwnProps {
const UNREAD_DIVIDER_CLASS = 'unread-divider';
const MessageListContent: FC<OwnProps> = ({
areAdsEnabled,
canShowAds,
chatId,
threadId,
messageIds,
@ -308,7 +308,7 @@ const MessageListContent: FC<OwnProps> = ({
key="fab-trigger"
className="fab-trigger"
/>
{areAdsEnabled && isViewportNewest && (
{canShowAds && isViewportNewest && (
<SponsoredMessage
key={chatId}
chatId={chatId}

View File

@ -22,6 +22,7 @@ import { applyAnimationState, type PaneState } from './hooks/useHeaderPane';
import GroupCallTopPane from '../calls/group/GroupCallTopPane';
import AudioPlayer from './panes/AudioPlayer';
import BotAdPane from './panes/BotAdPane';
import ChatReportPane from './panes/ChatReportPane';
import HeaderPinnedMessage from './panes/HeaderPinnedMessage';
@ -64,6 +65,7 @@ const MiddleHeaderPanes = ({
const [getPinnedState, setPinnedState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getGroupCallState, setGroupCallState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getChatReportState, setChatReportState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getBotAdState, setBotAdState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const isPinnedMessagesFullWidth = isAudioPlayerRendered || !isDesktop;
@ -85,9 +87,10 @@ const MiddleHeaderPanes = ({
const pinnedState = getPinnedState();
const groupCallState = getGroupCallState();
const chatReportState = getChatReportState();
const botAdState = getBotAdState();
// Keep in sync with the order of the panes in the DOM
const stateArray = [audioPlayerState, groupCallState, chatReportState, pinnedState];
const stateArray = [audioPlayerState, groupCallState, chatReportState, pinnedState, botAdState];
const isFirstRender = isFirstRenderRef.current;
const totalHeight = stateArray.reduce((acc, state) => acc + state.height, 0);
@ -100,7 +103,7 @@ const MiddleHeaderPanes = ({
setExtraStyles(middleColumn, {
'--middle-header-panes-height': `${totalHeight}px`,
});
}, [getAudioPlayerState, getGroupCallState, getPinnedState, getChatReportState]);
}, [getAudioPlayerState, getGroupCallState, getPinnedState, getChatReportState, getBotAdState]);
if (!shouldRender) return undefined;
@ -136,6 +139,11 @@ const MiddleHeaderPanes = ({
isFullWidth
shouldHide={!isPinnedMessagesFullWidth}
/>
<BotAdPane
chatId={chatId}
messageListType={messageListType}
onPaneStateChange={setBotAdState}
/>
</div>
);
};

View File

@ -1,5 +1,6 @@
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
@ -12,6 +13,8 @@ import { requestForcedReflow, requestNextMutation } from '../../../lib/fasterdom
import useTimeout from '../../../hooks/schedulers/useTimeout';
import useLastCallback from '../../../hooks/useLastCallback';
import useResizeObserver from '../../../hooks/useResizeObserver';
import useThrottledCallback from '../../../hooks/useThrottledCallback';
export interface PaneState {
element?: HTMLElement;
@ -21,16 +24,19 @@ export interface PaneState {
// Max slide transition duration
const CLOSE_DURATION = 450;
const RESIZE_THROTTLE = 100;
export default function useHeaderPane<RefType extends HTMLElement = HTMLDivElement>({
ref: providedRef,
isOpen,
isDisabled,
withResizeObserver,
onStateChange,
} : {
ref?: RefObject<RefType | null>;
isOpen?: boolean;
isDisabled?: boolean;
withResizeObserver?: boolean;
onStateChange?: (state: PaneState) => void;
}) {
const [shouldRender, setShouldRender] = useState(true);
@ -38,6 +44,8 @@ export default function useHeaderPane<RefType extends HTMLElement = HTMLDivEleme
const localRef = useRef<RefType>(null);
const ref = providedRef || localRef;
const lastHeightRef = useRef(0);
const reset = useLastCallback(() => {
setShouldRender(true);
onStateChange?.({
@ -65,7 +73,8 @@ export default function useHeaderPane<RefType extends HTMLElement = HTMLDivEleme
setShouldRender(false);
}, !isOpen ? CLOSE_DURATION : undefined);
useLayoutEffect(() => {
// Should be `useCallback` to trigger effect on deps change
const handleUpdate = useCallback(() => {
const element = ref.current;
if (isDisabled || !element || !shouldRender) return;
@ -80,6 +89,7 @@ export default function useHeaderPane<RefType extends HTMLElement = HTMLDivEleme
requestForcedReflow(() => {
const currentHeight = element.offsetHeight;
lastHeightRef.current = currentHeight;
return () => {
onStateChange?.({
element,
@ -90,6 +100,22 @@ export default function useHeaderPane<RefType extends HTMLElement = HTMLDivEleme
});
}, [isOpen, shouldRender, isDisabled, ref, onStateChange]);
const handleResize = useThrottledCallback(() => {
const element = ref.current;
if (!element) return;
const newHeight = element.offsetHeight;
if (newHeight === lastHeightRef.current) {
return;
}
handleUpdate();
}, [handleUpdate, ref], RESIZE_THROTTLE, true);
useLayoutEffect(handleUpdate, [handleUpdate]);
useResizeObserver(ref, handleResize, !withResizeObserver || !shouldRender);
return {
shouldRender,
ref,

View File

@ -12,7 +12,6 @@ import type {
ApiPeer,
ApiPoll,
ApiReaction,
ApiSponsoredMessage,
ApiStickerSet,
ApiThreadInfo,
ApiTypeStory,
@ -51,7 +50,7 @@ type OwnProps = {
isOpen: boolean;
anchor: IAnchorPosition;
targetHref?: string;
message: ApiMessage | ApiSponsoredMessage;
message: ApiMessage;
poll?: ApiPoll;
story?: ApiTypeStory;
canSendNow?: boolean;
@ -119,10 +118,6 @@ type OwnProps = {
onClosePoll?: NoneToVoidFunction;
onShowSeenBy?: NoneToVoidFunction;
onShowReactors?: NoneToVoidFunction;
onAboutAdsClick?: NoneToVoidFunction;
onSponsoredHide?: NoneToVoidFunction;
onSponsorInfo?: NoneToVoidFunction;
onSponsoredReport?: NoneToVoidFunction;
onTranslate?: NoneToVoidFunction;
onShowOriginal?: NoneToVoidFunction;
onSelectLanguage?: NoneToVoidFunction;
@ -215,10 +210,6 @@ const MessageContextMenu: FC<OwnProps> = ({
onSendPaidReaction,
onShowPaidReactionModal,
onCopyMessages,
onAboutAdsClick,
onSponsoredHide,
onSponsorInfo,
onSponsoredReport,
onReactionPickerOpen,
onTranslate,
onShowOriginal,
@ -234,10 +225,8 @@ const MessageContextMenu: FC<OwnProps> = ({
const lang = useOldLang();
const noReactions = !isPrivate && !enabledReactions;
const withReactions = canShowReactionList && !noReactions;
const isSponsoredMessage = !('id' in message);
const isEdited = ('isEdited' in message) && message.isEdited;
const messageId = !isSponsoredMessage ? message.id : '';
const seenByDates = !isSponsoredMessage ? message.seenByDates : undefined;
const seenByDates = message.seenByDates;
const [areItemsHidden, hideItems] = useFlag();
const [isReady, markIsReady, unmarkIsReady] = useFlag();
@ -286,23 +275,19 @@ const MessageContextMenu: FC<OwnProps> = ({
onClose();
});
const copyOptions = isSponsoredMessage
? []
: getMessageCopyOptions(
message,
groupStatetefulContent({ poll, story }),
targetHref,
canCopy,
handleAfterCopy,
canCopyLink ? onCopyLink : undefined,
onCopyMessages,
onCopyNumber,
);
const copyOptions = getMessageCopyOptions(
message,
groupStatetefulContent({ poll, story }),
targetHref,
canCopy,
handleAfterCopy,
canCopyLink ? onCopyLink : undefined,
onCopyMessages,
onCopyNumber,
);
const getTriggerElement = useLastCallback(() => {
return isSponsoredMessage
? document.querySelector('.Transition_slide-active > .MessageList .SponsoredMessage')
: document.querySelector(`.Transition_slide-active > .MessageList div[data-message-id="${messageId}"]`);
return document.querySelector(`.Transition_slide-active > .MessageList div[data-message-id="${message.id}"]`);
});
const getRootElement = useLastCallback(() => document.querySelector('.Transition_slide-active > .MessageList'));
@ -368,7 +353,7 @@ const MessageContextMenu: FC<OwnProps> = ({
topReactions={topReactions}
allAvailableReactions={availableReactions}
defaultTagReactions={defaultTagReactions}
currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined}
currentReactions={message.reactions?.results}
reactionsLimit={reactionsLimit}
onToggleReaction={onToggleReaction!}
onSendPaidReaction={onSendPaidReaction}
@ -464,26 +449,7 @@ const MessageContextMenu: FC<OwnProps> = ({
)}
</>
)}
{isSponsoredMessage && message.sponsorInfo && (
<MenuItem icon="channel" onClick={onSponsorInfo}>{lang('SponsoredMessageSponsor')}</MenuItem>
)}
{isSponsoredMessage && (
<MenuItem icon="info" onClick={onAboutAdsClick}>
{lang(message.canReport ? 'AboutRevenueSharingAds' : 'SponsoredMessageInfo')}
</MenuItem>
)}
{isSponsoredMessage && message.canReport && (
<MenuItem icon="hand-stop" onClick={onSponsoredReport}>
{lang('ReportAd')}
</MenuItem>
)}
{isSponsoredMessage && onSponsoredHide && (
<>
<MenuSeparator />
<MenuItem icon="close-circle" onClick={onSponsoredHide}>{lang('HideAd')}</MenuItem>
</>
)}
{(canShowSeenBy || canShowReactionsCount) && !isSponsoredMessage && (
{(canShowSeenBy || canShowReactionsCount) && (
<>
<MenuSeparator size={hasCustomEmoji ? 'thin' : 'thick'} />
<MenuItem
@ -518,10 +484,10 @@ const MessageContextMenu: FC<OwnProps> = ({
</MenuItem>
</>
)}
{((!isSponsoredMessage && (canLoadReadDate || shouldRenderShowWhen)) || isEdited) && (
{(canLoadReadDate || shouldRenderShowWhen || isEdited) && (
<MenuSeparator size={hasCustomEmoji ? 'thin' : 'thick'} />
)}
{!isSponsoredMessage && (canLoadReadDate || shouldRenderShowWhen) && (
{(canLoadReadDate || shouldRenderShowWhen) && (
<ReadTimeMenuItem
canLoadReadDate={canLoadReadDate}
shouldRenderShowWhen={shouldRenderShowWhen}

View File

@ -30,6 +30,9 @@
}
.message-type {
display: flex;
align-items: center;
gap: 0.25rem;
padding-inline-end: 0.25rem;
text-transform: capitalize;
@ -121,17 +124,6 @@
.ad-about {
font-size: 0.6875rem;
margin-inline-start: 0.25rem;
border-radius: 1rem;
padding-inline: 0.375rem;
transition: 150ms filter ease-in;
background: var(--accent-background-active-color);
cursor: var(--custom-cursor, pointer);
filter: brightness(1);
&:hover {
filter: brightness(1.1);
}
}
.has-media {

View File

@ -27,13 +27,12 @@ import { calculateMediaDimensions, getMinMediaWidth, MIN_MEDIA_WIDTH_WITH_TEXT }
import useAppLayout from '../../../hooks/useAppLayout';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useFlag from '../../../hooks/useFlag';
import { type ObserveFn, useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import AboutAdsModal from '../../common/AboutAdsModal.async';
import Avatar from '../../common/Avatar';
import BadgeButton from '../../common/BadgeButton';
import Icon from '../../common/icons/Icon';
import PeerColorWrapper from '../../common/PeerColorWrapper';
import Button from '../../ui/Button';
@ -77,8 +76,8 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
openUrl,
hideSponsoredMessages,
clickSponsoredMessage,
reportSponsoredMessage,
openMediaViewer,
openAboutAdsModal,
} = getActions();
const lang = useOldLang();
@ -101,7 +100,6 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref, undefined, true, IS_ANDROID);
const [isAboutAdsModalOpen, openAboutAdsModal, closeAboutAdsModal] = useFlag(false);
useEffect(() => {
return shouldObserve ? observeIntersection(contentRef.current!, (target) => {
@ -116,10 +114,6 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
handleBeforeContextMenu(e);
};
const handleReportSponsoredMessage = useLastCallback(() => {
reportSponsoredMessage({ peerId: chatId, randomId: message!.randomId });
});
const handleHideSponsoredMessage = useLastCallback(() => {
hideSponsoredMessages();
});
@ -135,7 +129,7 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
if (!message) return;
clickSponsoredMessage({ isMedia: photo || isGif ? true : undefined, peerId: chatId });
openUrl({ url: message!.url, shouldSkipModal: true });
openUrl({ url: message.url, shouldSkipModal: true });
});
const handleOpenMedia = useLastCallback(() => {
@ -147,6 +141,10 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
});
});
const handleOpenAboutAdsModal = useLastCallback(() => {
openAboutAdsModal({ chatId });
});
const extraPadding = 0;
const sizeCalculations = useMemo(() => {
@ -283,7 +281,9 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
)}
<span className={buildClassName('message-title message-type', hasMedia && 'has-media')}>
{message!.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')}
<span onClick={openAboutAdsModal} className="ad-about">{lang('SponsoredMessageAdWhatIsThis')}</span>
<BadgeButton onClick={handleOpenAboutAdsModal} className="ad-about">
{lang('SponsoredMessageAdWhatIsThis')}
</BadgeButton>
</span>
{renderContent()}
</PeerColorWrapper>
@ -318,18 +318,12 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
<SponsoredMessageContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
triggerRef={ref}
message={message!}
onAboutAdsClick={openAboutAdsModal}
onReportAd={handleReportSponsoredMessage}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
/>
)}
<AboutAdsModal
isOpen={isAboutAdsModalOpen}
isMonetizationSharing={message.canReport}
onClose={closeAboutAdsModal}
/>
</div>
);
};

View File

@ -0,0 +1,93 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useRef,
} from '../../../lib/teact/teact';
import type {
ApiSponsoredMessage,
} from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator';
import './MessageContextMenu.scss';
type OwnProps = {
isOpen: boolean;
anchor: IAnchorPosition;
message: ApiSponsoredMessage;
triggerRef: React.RefObject<HTMLElement>;
shouldSkipAbout?: boolean;
onClose: NoneToVoidFunction;
onCloseAnimationEnd?: NoneToVoidFunction;
onAboutAdsClick: NoneToVoidFunction;
onSponsoredHide: NoneToVoidFunction;
onSponsorInfo?: NoneToVoidFunction;
onSponsoredReport?: NoneToVoidFunction;
};
const SponsoredMessageContextMenu: FC<OwnProps> = ({
isOpen,
message,
anchor,
triggerRef,
shouldSkipAbout,
onClose,
onCloseAnimationEnd,
onAboutAdsClick,
onSponsoredHide,
onSponsorInfo,
onSponsoredReport,
}) => {
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
const lang = useOldLang();
const getTriggerElement = useLastCallback(() => triggerRef.current);
const getLayout = useLastCallback(() => ({ withPortal: true }));
const getMenuElement = useLastCallback(() => menuRef.current);
const getRootElement = useLastCallback(() => document.body);
const isSeparatorNeeded = message.sponsorInfo || !shouldSkipAbout || message.canReport;
return (
<Menu
ref={menuRef}
isOpen={isOpen}
anchor={anchor}
withPortal
className="with-menu-transitions"
getLayout={getLayout}
getTriggerElement={getTriggerElement}
getMenuElement={getMenuElement}
getRootElement={getRootElement}
onClose={onClose}
onCloseAnimationEnd={onCloseAnimationEnd}
>
{message.sponsorInfo && onSponsorInfo && (
<MenuItem icon="channel" onClick={onSponsorInfo}>{lang('SponsoredMessageSponsor')}</MenuItem>
)}
{!shouldSkipAbout && (
<MenuItem icon="info" onClick={onAboutAdsClick}>
{lang(message.canReport ? 'AboutRevenueSharingAds' : 'SponsoredMessageInfo')}
</MenuItem>
)}
{message.canReport && onSponsoredReport && (
<MenuItem icon="hand-stop" onClick={onSponsoredReport}>
{lang('ReportAd')}
</MenuItem>
)}
{isSeparatorNeeded && <MenuSeparator />}
<MenuItem icon="close-circle" onClick={onSponsoredHide}>
{lang('HideAd')}
</MenuItem>
</Menu>
);
};
export default memo(SponsoredMessageContextMenu);

View File

@ -5,60 +5,71 @@ import { getActions } from '../../../global';
import type { ApiSponsoredMessage } from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
import buildClassName from '../../../util/buildClassName';
import useFlag from '../../../hooks/useFlag';
import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated';
import useShowTransition from '../../../hooks/useShowTransition';
import MessageContextMenu from './MessageContextMenu';
import SponsoredMessageContextMenu from './SponsoredMessageContextMenu';
export type OwnProps = {
isOpen: boolean;
message: ApiSponsoredMessage;
anchor: IAnchorPosition;
onAboutAdsClick: NoneToVoidFunction;
onReportAd: NoneToVoidFunction;
triggerRef: React.RefObject<HTMLElement>;
shouldSkipAbout?: boolean;
onItemClick?: NoneToVoidFunction;
onClose: NoneToVoidFunction;
onCloseAnimationEnd: NoneToVoidFunction;
};
const SponsoredMessageContextMenuContainer: FC<OwnProps> = ({
isOpen,
message,
anchor,
onAboutAdsClick,
onReportAd,
triggerRef,
shouldSkipAbout,
onItemClick,
onClose,
onCloseAnimationEnd,
}) => {
const { openPremiumModal, showDialog } = getActions();
const {
openAboutAdsModal,
showDialog,
reportSponsoredMessage,
hideSponsoredMessages,
} = getActions();
const [isMenuOpen, , closeMenu] = useFlag(true);
const { transitionClassNames } = useShowTransitionDeprecated(isMenuOpen, onCloseAnimationEnd, undefined, false);
const handleAboutAdsOpen = useLastCallback(() => {
onAboutAdsClick();
closeMenu();
const { ref } = useShowTransition({
isOpen,
onCloseAnimationEnd,
});
const handleSponsoredHide = useLastCallback(() => {
closeMenu();
openPremiumModal();
const handleItemClick = useLastCallback(() => {
onItemClick?.();
onClose();
});
const handleAboutAdsOpen = useLastCallback(() => {
openAboutAdsModal({ chatId: message.chatId });
handleItemClick();
});
const handleSponsoredHide = useLastCallback(() => {
hideSponsoredMessages();
handleItemClick();
});
const handleSponsorInfo = useLastCallback(() => {
closeMenu();
showDialog({
data: {
message: [message.sponsorInfo, message.additionalInfo].join('\n'),
},
});
handleItemClick();
});
const handleReportSponsoredMessage = useLastCallback(() => {
closeMenu();
onReportAd();
reportSponsoredMessage({ peerId: message.chatId, randomId: message.randomId });
handleItemClick();
});
if (!anchor) {
@ -66,13 +77,15 @@ const SponsoredMessageContextMenuContainer: FC<OwnProps> = ({
}
return (
<div className={buildClassName('ContextMenuContainer', transitionClassNames)}>
<MessageContextMenu
isOpen={isMenuOpen}
<div ref={ref} className="ContextMenuContainer">
<SponsoredMessageContextMenu
isOpen={isOpen}
anchor={anchor}
triggerRef={triggerRef}
message={message}
onClose={closeMenu}
onCloseAnimationEnd={closeMenu}
shouldSkipAbout={shouldSkipAbout}
onClose={onClose}
onCloseAnimationEnd={onClose}
onAboutAdsClick={handleAboutAdsOpen}
onSponsoredHide={handleSponsoredHide}
onSponsorInfo={handleSponsorInfo}

View File

@ -0,0 +1,53 @@
@use "../../../styles/mixins";
.root {
@include mixins.header-pane;
display: flex;
align-items: center;
gap: 0.5rem;
height: auto;
line-height: 1.25;
cursor: var(--custom-cursor, pointer);
// Slight variation from mixins.header-pane
padding-left: max(1rem, env(safe-area-inset-left));
transition: background-color 0.2s, transform var(--slide-transition);
&:hover {
background-color: var(--color-background-selected);
}
}
.content {
flex-grow: 1;
}
.info {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--accent-color);
}
.avatar {
--radius: 0.25rem;
flex-shrink: 0;
}
.title, .info {
font-size: calc(var(--message-text-size, 1rem) - 0.125rem);
font-weight: 500;
line-height: 1.25rem;
}
.text {
font-size: 0.875rem;
}
.aboutAd {
font-size: 0.6875rem;
}

View File

@ -0,0 +1,153 @@
import React, { memo, useEffect } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiSponsoredMessage } from '../../../api/types';
import type { MessageListType } from '../../../global/types';
import { selectBot, selectSponsoredMessage } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { getApiPeerColorClass } from '../../common/helpers/peerColor';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane';
import Avatar from '../../common/Avatar';
import BadgeButton from '../../common/BadgeButton';
import SponsoredMessageContextMenuContainer from '../message/SponsoredMessageContextMenuContainer';
import styles from './BotAdPane.module.scss';
type OwnProps = {
chatId: string;
messageListType: MessageListType;
onPaneStateChange?: (state: PaneState) => void;
};
type StateProps = {
isBot?: boolean;
sponsoredMessage?: ApiSponsoredMessage;
};
const BotAdPane = ({
chatId,
isBot,
messageListType,
sponsoredMessage,
onPaneStateChange,
}: OwnProps & StateProps) => {
const {
viewSponsoredMessage,
openUrl,
clickSponsoredMessage,
openAboutAdsModal,
} = getActions();
const lang = useLang();
const isOpen = Boolean(isBot && sponsoredMessage && messageListType === 'thread');
const renderingSponsoredMessage = useCurrentOrPrev(sponsoredMessage);
const { ref, shouldRender } = useHeaderPane({
isOpen,
withResizeObserver: true,
onStateChange: onPaneStateChange,
});
const {
isContextMenuOpen, contextMenuAnchor,
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref, !shouldRender, true);
const handleClick = useLastCallback(() => {
if (!renderingSponsoredMessage) return;
clickSponsoredMessage({ peerId: chatId });
openUrl({ url: renderingSponsoredMessage.url, shouldSkipModal: true });
});
const handleAboutClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
openAboutAdsModal({ chatId });
});
useEffect(() => {
if (shouldRender && sponsoredMessage) {
viewSponsoredMessage({ peerId: chatId });
}
}, [shouldRender, sponsoredMessage, chatId]);
if (!shouldRender || !renderingSponsoredMessage) {
return undefined;
}
const {
peerColor,
content,
photo,
title,
} = renderingSponsoredMessage;
return (
<>
<div
ref={ref}
className={styles.root}
onClick={handleClick}
onMouseDown={handleBeforeContextMenu}
onContextMenu={handleContextMenu}
>
<div className={buildClassName(styles.content, peerColor && getApiPeerColorClass(peerColor))}>
<span className={styles.info}>
{lang('SponsoredMessageAd')}
<BadgeButton onClick={handleAboutClick} className={styles.aboutAd}>
{lang('SponsoredMessageAdWhatIsThis')}
</BadgeButton>
</span>
<div className={styles.title}>{title}</div>
{content.text && (
<div className={styles.text}>
{renderTextWithEntities({
text: content.text.text,
entities: content.text.entities,
})}
</div>
)}
</div>
{photo && (
<Avatar
size="large"
photo={photo}
className={styles.avatar}
/>
)}
</div>
{contextMenuAnchor && (
<SponsoredMessageContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
triggerRef={ref}
message={renderingSponsoredMessage}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
/>
)}
</>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const bot = selectBot(global, chatId);
const sponsoredMessage = selectSponsoredMessage(global, chatId);
return {
isBot: Boolean(bot),
sponsoredMessage,
};
},
)(BotAdPane));

View File

@ -7,6 +7,7 @@ import { selectTabState } from '../../global/selectors';
import { pick } from '../../util/iteratees';
import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal.async';
import AboutAdsModal from './aboutAds/AboutAdsModal.async';
import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async';
import BoostModal from './boost/BoostModal.async';
import ChatInviteModal from './chatInvite/ChatInviteModal.async';
@ -55,7 +56,8 @@ type ModalKey = keyof Pick<TabState,
'giftModal' |
'isGiftRecipientPickerOpen' |
'isWebAppsCloseConfirmationModalOpen' |
'giftInfoModal'
'giftInfoModal' |
'aboutAdsModal'
>;
type StateProps = {
@ -94,6 +96,7 @@ const MODALS: ModalRegistry = {
isGiftRecipientPickerOpen: GiftRecipientPicker,
isWebAppsCloseConfirmationModalOpen: WebAppsCloseConfirmationModal,
giftInfoModal: GiftInfoModal,
aboutAdsModal: AboutAdsModal,
};
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>;

View File

@ -1,15 +1,15 @@
import type { FC } from '../../lib/teact/teact';
import React from '../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact';
import type { OwnProps } from './AboutAdsModal';
import { Bundles } from '../../util/moduleLoader';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const AboutAdsModalAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const AboutAdsModal = useModuleLoader(Bundles.Extra, 'AboutAdsModal', !isOpen);
const { modal } = props;
const AboutAdsModal = useModuleLoader(Bundles.Extra, 'AboutAdsModal', !modal);
// eslint-disable-next-line react/jsx-props-no-spreading
return AboutAdsModal ? <AboutAdsModal {...props} /> : undefined;

View File

@ -8,6 +8,12 @@
padding-inline: 1.5rem;
}
.moreButton {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.secondary {
color: var(--color-text-secondary);
}
@ -16,4 +22,5 @@
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.25;
}

View File

@ -0,0 +1,185 @@
import React, { memo, useMemo, useRef } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiSponsoredMessage } from '../../../api/types';
import type { TabState } from '../../../global/types';
import type { TableAboutData } from '../common/TableAboutModal';
import { selectSponsoredMessage } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Icon from '../../common/icons/Icon';
import SafeLink from '../../common/SafeLink';
import SponsoredMessageContextMenuContainer from '../../middle/message/SponsoredMessageContextMenuContainer';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import TableAboutModal from '../common/TableAboutModal';
import styles from './AboutAdsModal.module.scss';
export type OwnProps = {
// eslint-disable-next-line react/no-unused-prop-types
modal: TabState['aboutAdsModal'];
};
type StateProps = {
message?: ApiSponsoredMessage;
minLevelToRestrictAds?: number;
};
const AboutAdsModal = ({ message, minLevelToRestrictAds }: OwnProps & StateProps) => {
const { closeAboutAdsModal } = getActions();
// eslint-disable-next-line no-null/no-null
const moreMenuRef = useRef<HTMLButtonElement>(null);
const isOpen = Boolean(message);
const isMonetizationSharing = message?.canReport;
const renderingIsNewDesign = useCurrentOrPrev(isMonetizationSharing);
const oldLang = useOldLang();
const regularAdContent = useMemo(() => {
return (
<>
<h3>{oldLang('SponsoredMessageInfoScreen.Title')}</h3>
<p>{renderText(oldLang('SponsoredMessageInfoDescription1'), ['br'])}</p>
<p>{renderText(oldLang('SponsoredMessageInfoDescription2'), ['br'])}</p>
<p>{renderText(oldLang('SponsoredMessageInfoDescription3'), ['br'])}</p>
<p>
<SafeLink
url={oldLang('SponsoredMessageAlertLearnMoreUrl')}
text={oldLang('SponsoredMessageAlertLearnMoreUrl')}
/>
</p>
<p>{renderText(oldLang('SponsoredMessageInfoDescription4'), ['br'])}</p>
</>
);
}, [oldLang]);
const {
isContextMenuOpen, contextMenuAnchor,
handleContextMenu, handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(moreMenuRef, !renderingIsNewDesign);
const handleClose = useLastCallback(() => {
closeAboutAdsModal();
handleContextMenuClose();
handleContextMenuHide();
});
const modalData = useMemo(() => {
if (!isOpen) return undefined;
const header = (
<>
<h3 className={styles.title}>{oldLang('AboutRevenueSharingAds')}</h3>
<p className={buildClassName(styles.description, styles.secondary)}>
{oldLang('RevenueSharingAdsAlertSubtitle')}
</p>
<Button
ref={moreMenuRef}
round
size="smaller"
color="translucent"
className={styles.moreButton}
onClick={handleContextMenu}
>
<Icon name="more" />
</Button>
</>
);
const listItemData = [
['lock', oldLang('RevenueSharingAdsInfo1Title'),
renderText(oldLang('RevenueSharingAdsInfo1Subtitle'), ['simple_markdown'])],
['revenue-split', oldLang('RevenueSharingAdsInfo2Title'),
renderText(oldLang('RevenueSharingAdsInfo2Subtitle'), ['simple_markdown'])],
['nochannel', oldLang('RevenueSharingAdsInfo3Title'),
renderText(oldLang('RevenueSharingAdsInfo3Subtitle', minLevelToRestrictAds), ['simple_markdown'])],
] satisfies TableAboutData;
const footer = (
<>
<h3 className={styles.title}>{renderText(oldLang('RevenueSharingAdsInfo4Title'), ['simple_markdown'])}</h3>
<p className={styles.description}>
{renderText(oldLang('RevenueSharingAdsInfo4Subtitle2', ''), ['simple_markdown'])}
<SafeLink
url={oldLang('PromoteUrl')}
text={oldLang('LearnMoreArrow')}
/>
</p>
</>
);
return {
header,
listItemData,
footer,
};
}, [isOpen, oldLang, handleContextMenu, minLevelToRestrictAds]);
if (renderingIsNewDesign) {
return (
<>
<TableAboutModal
isOpen={isOpen}
listItemData={modalData?.listItemData}
headerIconName="channel"
header={modalData?.header}
footer={modalData?.footer}
buttonText={oldLang('RevenueSharingAdsUnderstood')}
onClose={handleClose}
/>
{contextMenuAnchor && message && (
<SponsoredMessageContextMenuContainer
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
triggerRef={moreMenuRef}
message={message}
shouldSkipAbout
onItemClick={handleClose}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
/>
)}
</>
);
}
return (
<Modal
isOpen={isOpen}
className={styles.root}
contentClassName={styles.content}
onClose={handleClose}
>
{regularAdContent}
<Button
size="smaller"
onClick={handleClose}
>
{oldLang('RevenueSharingAdsUnderstood')}
</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const message = modal?.chatId ? selectSponsoredMessage(global, modal.chatId) : undefined;
const minLevelToRestrictAds = global.appConfig?.channelRestrictAdsLevelMin;
return {
message,
minLevelToRestrictAds,
};
},
)(AboutAdsModal));

View File

@ -31,9 +31,10 @@
display: flex;
flex-direction: column;
align-items: center;
overflow-x: hidden;
}
.separator {
margin-block: 1rem;
width: 110%;
width: calc(100% + 2rem); // Hack to cover modal paddings
}

View File

@ -59,7 +59,7 @@ const TableAboutModal = ({
<Separator className={styles.separator} />
{footer}
{buttonText && (
<Button onClick={onButtonClick || onClose}>{buttonText}</Button>
<Button size="smaller" onClick={onButtonClick || onClose}>{buttonText}</Button>
)}
</Modal>
);

View File

@ -2,6 +2,12 @@
overflow-x: hidden;
}
.root {
:global(.modal-dialog) {
max-width: 23rem;
}
}
.modalTitle {
display: flex;
flex-direction: column;
@ -15,7 +21,7 @@
}
.option {
margin-bottom: 0.25rem !important;
margin-bottom: 0rem !important;
}
.optionButton {

View File

@ -20,7 +20,7 @@ import Transition, { ACTIVE_SLIDE_CLASS_NAME, TO_SLIDE_CLASS_NAME } from '../../
import styles from './ReportAdModal.module.scss';
const ADDED_PADDING = 40;
const ADDED_PADDING = 56;
export type OwnProps = {
modal: TabState['reportAdModal'];
@ -64,7 +64,11 @@ const ReportAdModal = ({
const parts = template.split('{link}');
return [
parts[0],
<SafeLink text={lang('lng_report_sponsored_reported_link')} url={lang('ReportAd.Help_URL')} />,
<SafeLink
withNormalWordBreak
text={lang('lng_report_sponsored_reported_link')}
url={lang('ReportAd.Help_URL')}
/>,
parts[1],
];
}, [lang, modal]);
@ -123,6 +127,7 @@ const ReportAdModal = ({
<Modal
isOpen={isOpen}
hasCloseButton
className={styles.root}
header={header}
onClose={closeReportAdModal}
>

View File

@ -453,7 +453,7 @@
.subtitle {
font-size: 0.875rem;
line-height: 1.5rem;
line-height: 1.25rem;
color: var(--color-text-secondary);
& + .subtitle {

View File

@ -62,6 +62,7 @@ import {
isServiceNotificationMessage,
isUserBot,
} from '../../helpers';
import { isApiPeerUser } from '../../helpers/peers';
import {
addActionHandler, getActions, getGlobal, setGlobal,
} from '../../index';
@ -112,6 +113,7 @@ import {
selectFocusedMessageId,
selectForwardsCanBeSentToChat,
selectForwardsContainVoiceMessages,
selectIsChatBotNotStarted,
selectIsChatWithSelf,
selectIsCurrentUserPremium,
selectLanguageCode,
@ -1631,6 +1633,10 @@ addActionHandler('loadSponsoredMessages', async (global, actions, payload): Prom
return;
}
if (isApiPeerUser(peer) && selectIsChatBotNotStarted(global, peer.id)) {
return;
}
const result = await callApi('fetchSponsoredMessages', { peer });
if (!result) {
return;

View File

@ -1050,3 +1050,21 @@ addActionHandler('closeDeleteMessageModal', (global, actions, payload): ActionRe
deleteMessageModal: undefined,
}, tabId);
});
addActionHandler('openAboutAdsModal', (global, actions, payload): ActionReturnType => {
const { chatId, tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
aboutAdsModal: {
chatId,
},
}, tabId);
});
addActionHandler('closeAboutAdsModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
aboutAdsModal: undefined,
}, tabId);
});

View File

@ -1251,8 +1251,7 @@ export function selectCanForwardMessages<T extends GlobalState>(global: T, chatI
}
export function selectSponsoredMessage<T extends GlobalState>(global: T, chatId: string) {
const chat = selectChat(global, chatId);
const message = chat && isChatChannel(chat) ? global.messages.sponsoredByChatId[chatId] : undefined;
const message = global.messages.sponsoredByChatId[chatId];
return message && message.expiresAt >= Math.round(Date.now() / 1000) ? message : undefined;
}

View File

@ -388,6 +388,10 @@ export type TabState = {
messageId: number;
};
aboutAdsModal?: {
chatId: string;
};
reactionPicker?: {
chatId?: string;
messageId?: number;
@ -1750,6 +1754,10 @@ export interface ActionPayloads {
randomId: string;
option?: string;
} & WithTabId;
openAboutAdsModal: {
chatId: string;
} & WithTabId;
closeAboutAdsModal: WithTabId | undefined;
openPreviousReportAdModal: WithTabId | undefined;
openPreviousReportModal: WithTabId | undefined;
closeReportAdModal: WithTabId | undefined;

View File

@ -610,7 +610,8 @@ export interface LangPair {
'ChatPollTotalVotesResultEmpty': undefined;
'Vote': undefined;
'MessageRecommendedLabel': undefined;
'SponsoredMessage': undefined;
'SponsoredMessageAd': undefined;
'SponsoredMessageAdWhatIsThis': undefined;
'PremiumStickerTooltip': undefined;
'ViewAction': undefined;
'Loading': undefined;

View File

@ -116,10 +116,10 @@ type AdvancedLangFnParameters =
export type LangFnParameters = RegularLangFnParameters | AdvancedLangFnParameters;
export type LangFn = {
<K = RegularLangKey>(
<K extends RegularLangKey = RegularLangKey>(
key: K, variables?: undefined, options?: LangFnOptions,
): string;
<K = PluralLangKey>(
<K extends PluralLangKey = PluralLangKey>(
key: K, variables: undefined, options: LangFnOptionsWithPlural,
): string;
<K extends RegularLangKeyWithVariables = RegularLangKeyWithVariables, V = LangPairWithVariables[K]>(
@ -129,10 +129,10 @@ export type LangFn = {
key: K, variables: V, options: LangFnOptionsWithPlural,
): string;
<K = RegularLangKey>(
<K extends RegularLangKey = RegularLangKey>(
key: K, variables?: undefined, options?: AdvancedLangFnOptions,
): TeactNode;
<K = PluralLangKey>(
<K extends PluralLangKey = PluralLangKey>(
key: K, variables: undefined, options: AdvancedLangFnOptionsWithPlural,
): TeactNode;
<K extends RegularLangKeyWithVariables = RegularLangKeyWithVariables, V = LangPairWithVariables[K]>(