Sponsored Message: Update UI (#3989)

This commit is contained in:
Alexander Zinchuk 2023-12-04 14:37:55 +01:00
parent 1ca211978c
commit 54d0ec5d7e
7 changed files with 321 additions and 65 deletions

View File

@ -19,6 +19,7 @@ import type {
ApiReplyInfo, ApiReplyInfo,
ApiReplyKeyboard, ApiReplyKeyboard,
ApiSponsoredMessage, ApiSponsoredMessage,
ApiSponsoredWebPage,
ApiSticker, ApiSticker,
ApiStory, ApiStory,
ApiStorySkipped, ApiStorySkipped,
@ -77,7 +78,7 @@ export function setMessageBuilderCurrentUserId(_currentUserId: string) {
export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): ApiSponsoredMessage | undefined { export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): ApiSponsoredMessage | undefined {
const { const {
fromId, message, entities, startParam, channelPost, chatInvite, chatInviteHash, randomId, recommended, sponsorInfo, fromId, message, entities, startParam, channelPost, chatInvite, chatInviteHash, randomId, recommended, sponsorInfo,
additionalInfo, additionalInfo, showPeerPhoto, webpage,
} = mtpMessage; } = mtpMessage;
const chatId = fromId ? getApiChatIdFromMtpPeer(fromId) : undefined; const chatId = fromId ? getApiChatIdFromMtpPeer(fromId) : undefined;
const chatInviteTitle = chatInvite const chatInviteTitle = chatInvite
@ -92,6 +93,8 @@ export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): A
text: buildMessageTextContent(message, entities), 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: Boolean(recommended), isRecommended: Boolean(recommended),
...(webpage && { webPage: buildSponsoredWebPage(webpage) }),
...(showPeerPhoto && { isAvatarShown: true }),
...(chatId && { chatId }), ...(chatId && { chatId }),
...(chatInviteHash && { chatInviteHash }), ...(chatInviteHash && { chatInviteHash }),
...(chatInvite && { chatInviteTitle }), ...(chatInvite && { chatInviteTitle }),
@ -994,3 +997,19 @@ function buildThreadInfo(
...(recentRepliers && { recentReplierIds: recentRepliers.map(getApiChatIdFromMtpPeer) }), ...(recentRepliers && { recentReplierIds: recentRepliers.map(getApiChatIdFromMtpPeer) }),
}; };
} }
function buildSponsoredWebPage(webPage: GramJs.TypeSponsoredWebPage): ApiSponsoredWebPage {
let photo: ApiPhoto | undefined;
if (webPage.photo instanceof GramJs.Photo) {
addPhotoToLocalDb(webPage.photo);
photo = buildApiPhoto(webPage.photo);
}
return {
...pick(webPage, [
'url',
'siteName',
]),
photo,
};
}

View File

@ -310,6 +310,12 @@ export interface ApiWebPage {
story?: ApiWebPageStoryData; story?: ApiWebPageStoryData;
} }
export interface ApiSponsoredWebPage {
url: string;
siteName: string;
photo?: ApiPhoto;
}
export type ApiReplyInfo = ApiMessageReplyInfo | ApiStoryReplyInfo; export type ApiReplyInfo = ApiMessageReplyInfo | ApiStoryReplyInfo;
export interface ApiMessageReplyInfo { export interface ApiMessageReplyInfo {
@ -571,12 +577,14 @@ export type ApiSponsoredMessage = {
chatId?: string; chatId?: string;
randomId: string; randomId: string;
isRecommended?: boolean; isRecommended?: boolean;
isAvatarShown?: boolean;
isBot?: boolean; isBot?: boolean;
channelPostId?: number; channelPostId?: number;
startParam?: string; startParam?: string;
chatInviteHash?: string; chatInviteHash?: string;
chatInviteTitle?: string; chatInviteTitle?: string;
text: ApiFormattedText; text: ApiFormattedText;
webPage?: ApiSponsoredWebPage;
expiresAt: number; expiresAt: number;
sponsorInfo?: string; sponsorInfo?: string;
additionalInfo?: string; additionalInfo?: string;

View File

@ -157,6 +157,7 @@ import InlineButtons from './InlineButtons';
import Invoice from './Invoice'; import Invoice from './Invoice';
import InvoiceMediaPreview from './InvoiceMediaPreview'; import InvoiceMediaPreview from './InvoiceMediaPreview';
import Location from './Location'; import Location from './Location';
import MessageAppendix from './MessageAppendix';
import MessageMeta from './MessageMeta'; import MessageMeta from './MessageMeta';
import MessagePhoneCall from './MessagePhoneCall'; import MessagePhoneCall from './MessagePhoneCall';
import Photo from './Photo'; import Photo from './Photo';
@ -1423,30 +1424,6 @@ const Message: FC<OwnProps & StateProps> = ({
); );
}; };
function MessageAppendix({ isOwn } : { isOwn: boolean }) {
const path = isOwn
? 'M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z'
: 'M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z';
return (
<svg width="9" height="20" className="svg-appendix">
<defs>
<filter x="-50%" y="-14.7%" width="200%" height="141.2%" filterUnits="objectBoundingBox" id="messageAppendix">
<feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1" />
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1" />
<feColorMatrix
values="0 0 0 0 0.0621962482 0 0 0 0 0.138574144 0 0 0 0 0.185037364 0 0 0 0.15 0"
in="shadowBlurOuter1"
/>
</filter>
</defs>
<g fill="none" fill-rule="evenodd">
<path d={path} fill="#000" filter="url(#messageAppendix)" />
<path d={path} fill={isOwn ? '#EEFFDE' : 'FFF'} className="corner" />
</g>
</svg>
);
}
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, ownProps): StateProps => { (global, ownProps): StateProps => {
const { const {

View File

@ -0,0 +1,31 @@
import React from '../../../lib/teact/teact';
interface OwnProps {
isOwn?: boolean;
}
function MessageAppendix({ isOwn } : OwnProps) {
const path = isOwn
? 'M6 17H0V0c.193 2.84.876 5.767 2.05 8.782.904 2.325 2.446 4.485 4.625 6.48A1 1 0 016 17z'
: 'M3 17h6V0c-.193 2.84-.876 5.767-2.05 8.782-.904 2.325-2.446 4.485-4.625 6.48A1 1 0 003 17z';
return (
<svg width="9" height="20" className="svg-appendix">
<defs>
<filter x="-50%" y="-14.7%" width="200%" height="141.2%" filterUnits="objectBoundingBox" id="messageAppendix">
<feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1" />
<feGaussianBlur stdDeviation="1" in="shadowOffsetOuter1" result="shadowBlurOuter1" />
<feColorMatrix
values="0 0 0 0 0.0621962482 0 0 0 0 0.138574144 0 0 0 0 0.185037364 0 0 0 0.15 0"
in="shadowBlurOuter1"
/>
</filter>
</defs>
<g fill="none" fill-rule="evenodd">
<path d={path} fill="#000" filter="url(#messageAppendix)" />
<path d={path} fill={isOwn ? '#EEFFDE' : 'FFF'} className="corner" />
</g>
</svg>
);
}
export default MessageAppendix;

View File

@ -9,10 +9,122 @@
display: none; display: none;
} }
&__button.secondary { &__button {
margin-top: 0.5rem; --riple-color: var(var(--accent-background-active-color));
border: 1px solid var(--color-primary);
border-radius: var(--border-radius-default-tiny); margin-top: 0.375rem;
color: var(--color-primary); margin-bottom: -0.375rem;
border-top: 1px solid var(--accent-background-active-color, var(--active-color));
color: var(--accent-color) !important;
transition: opacity 0.2s ease-in;
&:hover, &:active {
background-color: transparent !important;
opacity: 0.85;
}
}
.message-type {
text-transform: capitalize;
}
.message-peer {
color: var(--color-text);
}
&.with-avatar {
--border-bottom-left-radius: 0 !important;
padding-left: 2.5rem !important;
& > .Avatar {
display: flex !important;
}
@media (max-width: 600px) {
padding-left: 2.875rem !important;
.message-content {
max-width: min(29rem, calc(100vw - 7.0625rem)) !important;
}
}
}
.message-action-button {
bottom: auto !important;
top: 0.5rem;
}
.message-content {
padding: 0.5rem;
@media (max-width: 600px) {
max-width: min(29rem, calc(100vw - 4.5rem)) !important;
}
}
.channel-avatar {
--radius: 0.125rem;
float: right;
margin: 0 0 0.5rem 0.5rem;
&.is-rtl {
float: left;
margin: 0 0.5rem 0.5rem 0;
}
}
.content-inner {
padding-top: 0.375rem;
padding-inline-end: 0.375rem;
padding-bottom: 0;
padding-inline-start: 0.625rem;
font-size: calc(var(--message-text-size, 1rem) - 0.125rem);
background-color: var(--accent-background-color);
border-radius: 0.375rem;
position: relative;
overflow: hidden;
&::before {
content: "";
display: block;
position: absolute;
top: 0;
inset-inline-start: 0;
bottom: 0;
width: 3px;
background: var(--bar-gradient, var(--accent-color));
}
> .Button {
border-radius: 0 0 0.375rem 0.375rem;
border: none;
background: none;
margin-bottom: 0;
line-height: 1;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 0.0625rem;
background: var(--accent-color);
opacity: 0.25;
}
}
.icon {
position: absolute;
font-size: 0.75rem;
top: 0.25rem;
right: 0;
transform: rotate(-45deg);
}
} }
} }

View File

@ -1,19 +1,22 @@
import type { RefObject } from 'react'; import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, { memo, useEffect, useRef } from '../../../lib/teact/teact';
memo, useEffect, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiSponsoredMessage, ApiUser } from '../../../api/types'; import type {
ApiChat, ApiSponsoredMessage, ApiUser,
} from '../../../api/types';
import { getChatTitle, getUserFullName } from '../../../global/helpers'; import { getChatTitle, getUserFullName } from '../../../global/helpers';
import { selectChat, selectSponsoredMessage, selectUser } from '../../../global/selectors'; import { selectChat, selectSponsoredMessage, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { IS_ANDROID, IS_TOUCH_ENV } from '../../../util/windowEnvironment'; import { IS_ANDROID, IS_TOUCH_ENV } from '../../../util/windowEnvironment';
import { getPeerColorClass } from '../../common/helpers/peerColor';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur'; import { preventMessageInputBlur } from '../helpers/preventMessageInputBlur';
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 { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
@ -21,7 +24,9 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import AboutAdsModal from '../../common/AboutAdsModal.async'; import AboutAdsModal from '../../common/AboutAdsModal.async';
import Avatar from '../../common/Avatar';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import MessageAppendix from './MessageAppendix';
import SponsoredMessageContextMenuContainer from './SponsoredMessageContextMenuContainer.async'; import SponsoredMessageContextMenuContainer from './SponsoredMessageContextMenuContainer.async';
import './SponsoredMessage.scss'; import './SponsoredMessage.scss';
@ -33,6 +38,7 @@ type OwnProps = {
type StateProps = { type StateProps = {
message?: ApiSponsoredMessage; message?: ApiSponsoredMessage;
peer?: ApiChat;
bot?: ApiUser; bot?: ApiUser;
channel?: ApiChat; channel?: ApiChat;
}; };
@ -41,6 +47,7 @@ const INTERSECTION_DEBOUNCE_MS = 200;
const SponsoredMessage: FC<OwnProps & StateProps> = ({ const SponsoredMessage: FC<OwnProps & StateProps> = ({
chatId, chatId,
peer,
message, message,
containerRef, containerRef,
bot, bot,
@ -52,7 +59,10 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
openChatByInvite, openChatByInvite,
startBot, startBot,
focusMessage, focusMessage,
openUrl,
openPremiumModal,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -72,6 +82,8 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
handleContextMenuClose, handleContextMenuHide, handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref, IS_TOUCH_ENV, true, IS_ANDROID); } = useContextMenuHandlers(ref, IS_TOUCH_ENV, true, IS_ANDROID);
const [isAboutAdsModalOpen, openAboutAdsModal, closeAboutAdsModal] = useFlag(false); const [isAboutAdsModalOpen, openAboutAdsModal, closeAboutAdsModal] = useFlag(false);
const { isMobile } = useAppLayout();
const withAvatar = Boolean(message?.isAvatarShown && peer);
useEffect(() => { useEffect(() => {
return shouldObserve ? observeIntersection(contentRef.current!, (target) => { return shouldObserve ? observeIntersection(contentRef.current!, (target) => {
@ -86,6 +98,25 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
handleBeforeContextMenu(e); handleBeforeContextMenu(e);
}; };
const handleAvatarClick = useLastCallback(() => {
if (!peer) {
return;
}
openChat({ id: peer.id });
});
const handleLinkClick = useLastCallback((e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
openUrl({ url: message!.webPage!.url, shouldSkipModal: true });
return false;
});
const handleCloseSponsoredMessage = useLastCallback(() => {
openPremiumModal();
});
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
if (!message) return; if (!message) return;
if (message.chatInviteHash) { if (message.chatInviteHash) {
@ -108,42 +139,119 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
return undefined; return undefined;
} }
function renderAvatar() {
return (
<Avatar
size={isMobile ? 'small-mobile' : 'small'}
peer={peer}
onClick={peer ? handleAvatarClick : undefined}
/>
);
}
function renderContent() {
if (message?.webPage) {
return (
<>
<div className="text-content with-meta" dir="auto" ref={contentRef}>
<div className="message-title message-peer" dir="ltr">
{renderText(message.webPage.siteName)}
</div>
<span className="text-content-inner" dir="auto">
{renderTextWithEntities({
text: message!.text.text,
entities: message!.text.entities,
})}
</span>
</div>
<Button
className="SponsoredMessage__button"
size="tiny"
color="translucent"
isRectangular
onClick={handleLinkClick}
>
<i className="icon icon-arrow-right" aria-hidden />
{lang('OpenLink')}
</Button>
</>
);
}
return (
<>
<div className="message-title message-peer" dir="auto">
{bot && renderText(getUserFullName(bot) || '')}
{channel && renderText(message!.chatInviteTitle || getChatTitle(lang, channel) || '')}
</div>
<div className="text-content with-meta" dir="auto" ref={contentRef}>
<span className="text-content-inner" dir="auto">
{renderTextWithEntities({
text: message!.text.text,
entities: message!.text.entities,
})}
</span>
</div>
<Button
className="SponsoredMessage__button"
size="tiny"
color="translucent"
isRectangular
onClick={handleClick}
>
{lang(message!.isBot
? 'Conversation.ViewBot'
: (message!.channelPostId ? 'Conversation.ViewPost' : 'Conversation.ViewChannel'))}
</Button>
</>
);
}
const contentClassName = buildClassName(
'message-content has-shadow has-solid-background',
withAvatar && 'has-appendix',
getPeerColorClass(peer || channel, true, true),
);
return ( return (
<div <div
ref={ref} ref={ref}
key="sponsored-message" key="sponsored-message"
className="SponsoredMessage Message open" className={buildClassName('SponsoredMessage Message open', withAvatar && 'with-avatar')}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
> >
<div className="message-content has-shadow has-solid-background" dir="auto"> {withAvatar && renderAvatar()}
<div
className={contentClassName}
dir="auto"
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
>
<div className="content-inner" dir="auto"> <div className="content-inner" dir="auto">
<div className="message-title" dir="ltr"> {channel && (
{bot && renderText(getUserFullName(bot) || '')} <Avatar
{channel && renderText(message.chatInviteTitle || getChatTitle(lang, channel) || '')} size="large"
</div> peer={channel}
className={buildClassName('channel-avatar', lang.isRtl && 'is-rtl')}
<div className="text-content with-meta" dir="auto" ref={contentRef}> />
<span className="text-content-inner" dir="auto"> )}
{renderTextWithEntities({ <span className="message-title message-type">
text: message.text.text, {message!.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')}
entities: message.text.entities, </span>
})} {renderContent()}
</span>
<span className="MessageMeta" dir="ltr">
<span className="message-signature">
{message.isRecommended ? lang('Message.RecommendedLabel') : lang('SponsoredMessage')}
</span>
</span>
</div>
<Button color="secondary" size="tiny" ripple onClick={handleClick} className="SponsoredMessage__button">
{lang(message.isBot
? 'Conversation.ViewBot'
: (message.channelPostId ? 'Conversation.ViewPost' : 'Conversation.ViewChannel'))}
</Button>
</div> </div>
{withAvatar && <MessageAppendix />}
<Button
className="message-action-button"
color="translucent-white"
round
size="tiny"
ariaLabel={lang('Close')}
onClick={handleCloseSponsoredMessage}
>
<i className="icon icon-close" aria-hidden />
</Button>
</div> </div>
{contextMenuPosition && ( {contextMenuPosition && (
<SponsoredMessageContextMenuContainer <SponsoredMessageContextMenuContainer
@ -166,10 +274,12 @@ const SponsoredMessage: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => { (global, { chatId }): StateProps => {
const message = selectSponsoredMessage(global, chatId); const message = selectSponsoredMessage(global, chatId);
const peer = message?.chatId ? selectChat(global, message?.chatId) : undefined;
const { chatId: fromChatId, isBot } = message || {}; const { chatId: fromChatId, isBot } = message || {};
return { return {
message, message,
peer,
bot: fromChatId && isBot ? selectUser(global, fromChatId) : undefined, bot: fromChatId && isBot ? selectUser(global, fromChatId) : undefined,
channel: !isBot && fromChatId ? selectChat(global, fromChatId) : undefined, channel: !isBot && fromChatId ? selectChat(global, fromChatId) : undefined,
}; };

View File

@ -125,7 +125,6 @@
.site-description { .site-description {
&:last-child::after { &:last-child::after {
content: ""; content: "";
display: inline-block;
width: var(--meta-safe-area-size); width: var(--meta-safe-area-size);
height: 0.75rem; height: 0.75rem;
float: right; float: right;