Web Page: Display stickerset preview (#4529)

This commit is contained in:
Alexander Zinchuk 2024-05-03 14:38:26 +02:00
parent 1f834d42ed
commit 85bc6cb297
8 changed files with 136 additions and 36 deletions

View File

@ -19,6 +19,7 @@ import type {
ApiVoice, ApiVoice,
ApiWebDocument, ApiWebDocument,
ApiWebPage, ApiWebPage,
ApiWebPageStickerData,
ApiWebPageStoryData, ApiWebPageStoryData,
MediaContent, MediaContent,
} from '../../types'; } from '../../types';
@ -35,7 +36,7 @@ import {
buildApiThumbnailFromStripped, buildApiThumbnailFromStripped,
} from './common'; } from './common';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { buildStickerFromDocument } from './symbols'; import { buildStickerFromDocument, processStickerResult } from './symbols';
export function buildMessageContent( export function buildMessageContent(
mtpMessage: UniversalMessage | GramJs.UpdateServiceNotification, mtpMessage: UniversalMessage | GramJs.UpdateServiceNotification,
@ -694,8 +695,9 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
audio = buildAudioFromDocument(document); audio = buildAudioFromDocument(document);
} }
let story: ApiWebPageStoryData | undefined; let story: ApiWebPageStoryData | undefined;
let stickers: ApiWebPageStickerData | undefined;
const attributeStory = attributes const attributeStory = attributes
?.find((a: any): a is GramJs.WebPageAttributeStory => a instanceof GramJs.WebPageAttributeStory); ?.find((a): a is GramJs.WebPageAttributeStory => a instanceof GramJs.WebPageAttributeStory);
if (attributeStory) { if (attributeStory) {
const peerId = getApiChatIdFromMtpPeer(attributeStory.peer); const peerId = getApiChatIdFromMtpPeer(attributeStory.peer);
story = { story = {
@ -707,6 +709,16 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
addStoryToLocalDb(attributeStory.story, peerId); addStoryToLocalDb(attributeStory.story, peerId);
} }
} }
const attributeStickers = attributes?.find((a): a is GramJs.WebPageAttributeStickerSet => (
a instanceof GramJs.WebPageAttributeStickerSet
));
if (attributeStickers) {
stickers = {
documents: processStickerResult(attributeStickers.stickers),
isEmoji: attributeStickers.emojis,
isWithTextColor: attributeStickers.textColor,
};
}
return { return {
id: Number(id), id: Number(id),
@ -724,6 +736,7 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
video, video,
audio, audio,
story, story,
stickers,
}; };
} }

View File

@ -3,7 +3,7 @@ import type { ApiWebDocument } from './bots';
import type { ApiGroupCall, PhoneCallAction } from './calls'; import type { ApiGroupCall, PhoneCallAction } from './calls';
import type { ApiChat, ApiPeerColor } from './chats'; import type { ApiChat, ApiPeerColor } from './chats';
import type { ApiInputStorePaymentPurpose, ApiPremiumGiftCodeOption } from './payments'; import type { ApiInputStorePaymentPurpose, ApiPremiumGiftCodeOption } from './payments';
import type { ApiMessageStoryData, ApiWebPageStoryData } from './stories'; import type { ApiMessageStoryData, ApiWebPageStickerData, ApiWebPageStoryData } from './stories';
export interface ApiDimensions { export interface ApiDimensions {
width: number; width: number;
@ -380,6 +380,7 @@ export interface ApiWebPage {
document?: ApiDocument; document?: ApiDocument;
video?: ApiVideo; video?: ApiVideo;
story?: ApiWebPageStoryData; story?: ApiWebPageStoryData;
stickers?: ApiWebPageStickerData;
} }
export type ApiReplyInfo = ApiMessageReplyInfo | ApiStoryReplyInfo; export type ApiReplyInfo = ApiMessageReplyInfo | ApiStoryReplyInfo;

View File

@ -1,6 +1,6 @@
import type { ApiPrivacySettings } from '../../types'; import type { ApiPrivacySettings } from '../../types';
import type { import type {
ApiGeoPoint, ApiMessage, ApiReaction, ApiReactionCount, ApiStoryForwardInfo, MediaContent, ApiGeoPoint, ApiMessage, ApiReaction, ApiReactionCount, ApiSticker, ApiStoryForwardInfo, MediaContent,
} from './messages'; } from './messages';
export interface ApiStory { export interface ApiStory {
@ -73,6 +73,12 @@ export type ApiWebPageStoryData = {
peerId: string; peerId: string;
}; };
export type ApiWebPageStickerData = {
documents: ApiSticker[];
isEmoji?: boolean;
isWithTextColor?: boolean;
};
export type ApiStoryViewPublicForward = { export type ApiStoryViewPublicForward = {
type: 'forward'; type: 'forward';
peerId: string; peerId: string;

View File

@ -1230,7 +1230,8 @@ const Message: FC<OwnProps & StateProps> = ({
{webPage && ( {webPage && (
<WebPage <WebPage
message={message} message={message}
observeIntersection={observeIntersectionForLoading} observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoadMedia} canAutoLoad={canAutoLoadMedia}
canAutoPlay={canAutoPlayMedia} canAutoPlay={canAutoPlayMedia}

View File

@ -22,6 +22,23 @@
margin: -0.375rem; margin: -0.375rem;
} }
&--emoji-grid {
display: grid !important;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 0.25rem;
}
&--sticker {
position: relative;
width: 100%;
height: 100%;
}
&--stickers {
color: var(--accent-color);
}
&.in-preview { &.in-preview {
border-radius: 0.25rem; border-radius: 0.25rem;
background-color: var(--color-primary-tint); background-color: var(--color-primary-tint);

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact'; import React, { memo, useRef } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { ApiMessage, ApiTypeStory } from '../../../api/types'; import type { ApiMessage, ApiTypeStory } from '../../../api/types';
@ -13,6 +13,7 @@ import renderText from '../../common/helpers/renderText';
import { calculateMediaDimensions } from './helpers/mediaDimensions'; import { calculateMediaDimensions } from './helpers/mediaDimensions';
import { getWebpageButtonText } from './helpers/webpageType'; import { getWebpageButtonText } from './helpers/webpageType';
import useDynamicColorListener from '../../../hooks/stickers/useDynamicColorListener';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import useEnsureStory from '../../../hooks/useEnsureStory'; import useEnsureStory from '../../../hooks/useEnsureStory';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -22,6 +23,7 @@ import Audio from '../../common/Audio';
import Document from '../../common/Document'; import Document from '../../common/Document';
import EmojiIconBackground from '../../common/embedded/EmojiIconBackground'; import EmojiIconBackground from '../../common/embedded/EmojiIconBackground';
import SafeLink from '../../common/SafeLink'; import SafeLink from '../../common/SafeLink';
import StickerView from '../../common/StickerView';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import BaseStory from './BaseStory'; import BaseStory from './BaseStory';
import Photo from './Photo'; import Photo from './Photo';
@ -31,10 +33,13 @@ import './WebPage.scss';
const MAX_TEXT_LENGTH = 170; // symbols const MAX_TEXT_LENGTH = 170; // symbols
const WEBPAGE_STORY_TYPE = 'telegram_story'; const WEBPAGE_STORY_TYPE = 'telegram_story';
const STICKER_SIZE = 80;
const EMOJI_SIZE = 40;
type OwnProps = { type OwnProps = {
message: ApiMessage; message: ApiMessage;
observeIntersection?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
noAvatars?: boolean; noAvatars?: boolean;
canAutoLoad?: boolean; canAutoLoad?: boolean;
canAutoPlay?: boolean; canAutoPlay?: boolean;
@ -55,7 +60,8 @@ type OwnProps = {
const WebPage: FC<OwnProps> = ({ const WebPage: FC<OwnProps> = ({
message, message,
observeIntersection, observeIntersectionForLoading,
observeIntersectionForPlaying,
noAvatars, noAvatars,
canAutoLoad, canAutoLoad,
canAutoPlay, canAutoPlay,
@ -76,6 +82,10 @@ const WebPage: FC<OwnProps> = ({
const { openTelegramLink } = getActions(); const { openTelegramLink } = getActions();
const webPage = getMessageWebPage(message); const webPage = getMessageWebPage(message);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const stickersRef = useRef<HTMLDivElement>(null);
const lang = useLang(); const lang = useLang();
@ -90,10 +100,13 @@ const WebPage: FC<OwnProps> = ({
}); });
}); });
const { story: storyData } = webPage || {}; const { story: storyData, stickers } = webPage || {};
useEnsureStory(storyData?.peerId, storyData?.id, story); useEnsureStory(storyData?.peerId, storyData?.id, story);
const hasCustomColor = stickers?.isWithTextColor || stickers?.documents?.[0]?.shouldUseTextColor;
const customColor = useDynamicColorListener(stickersRef, !hasCustomColor);
if (!webPage) { if (!webPage) {
return undefined; return undefined;
} }
@ -115,7 +128,7 @@ const WebPage: FC<OwnProps> = ({
const quickButtonLangKey = !inPreview && !isExpiredStory ? getWebpageButtonText(type) : undefined; const quickButtonLangKey = !inPreview && !isExpiredStory ? getWebpageButtonText(type) : undefined;
const truncatedDescription = trimText(description, MAX_TEXT_LENGTH); const truncatedDescription = trimText(description, MAX_TEXT_LENGTH);
const isArticle = Boolean(truncatedDescription || title || siteName); const isArticle = Boolean(truncatedDescription || title || siteName);
let isSquarePhoto = false; let isSquarePhoto = Boolean(stickers);
if (isArticle && webPage?.photo && !webPage.video) { if (isArticle && webPage?.photo && !webPage.video) {
const { width, height } = calculateMediaDimensions(message, undefined, undefined, isMobile); const { width, height } = calculateMediaDimensions(message, undefined, undefined, isMobile);
isSquarePhoto = width === height; isSquarePhoto = width === height;
@ -149,18 +162,25 @@ const WebPage: FC<OwnProps> = ({
return ( return (
<div <div
ref={ref}
className={className} className={className}
data-initial={(siteName || displayUrl)[0]} data-initial={(siteName || displayUrl)[0]}
dir={lang.isRtl ? 'rtl' : 'auto'} dir={lang.isRtl ? 'rtl' : 'auto'}
> >
<div className={buildClassName('WebPage--content', isStory && 'is-story')}> <div className={buildClassName('WebPage--content', isStory && 'is-story')}>
{backgroundEmojiId && (
<EmojiIconBackground
emojiDocumentId={backgroundEmojiId}
className="WebPage--background-icons"
/>
)}
{isStory && ( {isStory && (
<BaseStory story={story} isProtected={isProtected} isConnected={isConnected} isPreview /> <BaseStory story={story} isProtected={isProtected} isConnected={isConnected} isPreview />
)} )}
{photo && !video && ( {photo && !video && (
<Photo <Photo
message={message} message={message}
observeIntersection={observeIntersection} observeIntersection={observeIntersectionForLoading}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
size={isSquarePhoto ? 'pictogram' : 'inline'} size={isSquarePhoto ? 'pictogram' : 'inline'}
@ -175,12 +195,6 @@ const WebPage: FC<OwnProps> = ({
)} )}
{isArticle && ( {isArticle && (
<div className="WebPage-text"> <div className="WebPage-text">
{backgroundEmojiId && (
<EmojiIconBackground
emojiDocumentId={backgroundEmojiId}
className="WebPage--background-icons"
/>
)}
<SafeLink className="site-name" url={url} text={siteName || displayUrl} /> <SafeLink className="site-name" url={url} text={siteName || displayUrl} />
{!inPreview && title && ( {!inPreview && title && (
<p className="site-title">{renderText(title)}</p> <p className="site-title">{renderText(title)}</p>
@ -193,7 +207,7 @@ const WebPage: FC<OwnProps> = ({
{!inPreview && video && ( {!inPreview && video && (
<Video <Video
message={message} message={message}
observeIntersectionForLoading={observeIntersection!} observeIntersectionForLoading={observeIntersectionForLoading!}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
canAutoPlay={canAutoPlay} canAutoPlay={canAutoPlay}
@ -218,7 +232,7 @@ const WebPage: FC<OwnProps> = ({
{!inPreview && document && ( {!inPreview && document && (
<Document <Document
message={message} message={message}
observeIntersection={observeIntersection} observeIntersection={observeIntersectionForLoading}
autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
onCancelUpload={onCancelMediaTransfer} onCancelUpload={onCancelMediaTransfer}
@ -226,14 +240,30 @@ const WebPage: FC<OwnProps> = ({
shouldWarnAboutSvg={shouldWarnAboutSvg} shouldWarnAboutSvg={shouldWarnAboutSvg}
/> />
)} )}
{!inPreview && stickers && (
<div
ref={stickersRef}
className={buildClassName(
'media-inner', 'square-image', stickers.isEmoji && 'WebPage--emoji-grid', 'WebPage--stickers',
)}
>
{stickers.documents.map((sticker) => (
<div key={sticker.id} className="WebPage--sticker">
<StickerView
containerRef={stickersRef}
sticker={sticker}
shouldLoop
size={stickers.isEmoji ? EMOJI_SIZE : STICKER_SIZE}
customColor={customColor}
observeIntersectionForPlaying={observeIntersectionForPlaying}
observeIntersectionForLoading={observeIntersectionForLoading}
/>
</div>
))}
</div>
)}
{inPreview && displayUrl && !isArticle && ( {inPreview && displayUrl && !isArticle && (
<div className="WebPage-text"> <div className="WebPage-text">
{backgroundEmojiId && (
<EmojiIconBackground
emojiDocumentId={backgroundEmojiId}
className="WebPage--background-icons"
/>
)}
<p className="site-name">{displayUrl}</p> <p className="site-name">{displayUrl}</p>
<p className="site-description">{lang('Chat.Empty.LinkPreview')}</p> <p className="site-description">{lang('Chat.Empty.LinkPreview')}</p>
</div> </div>

View File

@ -29,6 +29,8 @@ export function getWebpageButtonText(type?: string) {
case 'telegram_channel_boost': case 'telegram_channel_boost':
case 'telegram_group_boost': case 'telegram_group_boost':
return 'lng_view_button_boost'; return 'lng_view_button_boost';
case 'telegram_stickerset':
return 'lng_view_button_stickerset';
default: default:
return undefined; return undefined;
} }

View File

@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from '../lib/teact/teact';
import type { Scheduler } from '../util/schedulers'; import type { Scheduler } from '../util/schedulers';
import { type CallbackManager, createCallbackManager } from '../util/callbacks';
import { import {
debounce, throttle, throttleWith, debounce, throttle, throttleWith,
} from '../util/schedulers'; } from '../util/schedulers';
@ -16,7 +17,9 @@ export type ObserveFn = (target: HTMLElement, targetCallback?: TargetCallback) =
interface IntersectionController { interface IntersectionController {
observer: IntersectionObserver; observer: IntersectionObserver;
callbacks: Map<HTMLElement, TargetCallback>; addCallback: (element: HTMLElement, callback: TargetCallback) => void;
removeCallback: (element: HTMLElement, callback: TargetCallback) => void;
destroy: NoneToVoidFunction;
} }
interface Response { interface Response {
@ -78,14 +81,14 @@ export function useIntersectionObserver({
return () => { return () => {
if (controllerRef.current) { if (controllerRef.current) {
controllerRef.current.observer.disconnect(); controllerRef.current.observer.disconnect();
controllerRef.current.callbacks.clear(); controllerRef.current.destroy();
controllerRef.current = undefined; controllerRef.current = undefined;
} }
}; };
}, [isDisabled]); }, [isDisabled]);
function initController() { function initController() {
const callbacks = new Map(); const callbacks = new Map<HTMLElement, CallbackManager<TargetCallback>>();
const entriesAccumulator = new Map<Element, IntersectionObserverEntry>(); const entriesAccumulator = new Map<Element, IntersectionObserverEntry>();
let observerCallback: typeof observerCallbackSync; let observerCallback: typeof observerCallbackSync;
@ -108,10 +111,8 @@ export function useIntersectionObserver({
const entries = Array.from(entriesAccumulator.values()); const entries = Array.from(entriesAccumulator.values());
entries.forEach((entry: IntersectionObserverEntry) => { entries.forEach((entry: IntersectionObserverEntry) => {
const callback = callbacks.get(entry.target); const callbackManager = callbacks.get(entry.target as HTMLElement);
if (callback) { callbackManager?.runCallbacks(entry);
callback!(entry, entries);
}
}); });
if (rootCallbackRef.current) { if (rootCallbackRef.current) {
@ -121,6 +122,25 @@ export function useIntersectionObserver({
entriesAccumulator.clear(); entriesAccumulator.clear();
} }
function addCallback(element: HTMLElement, callback: TargetCallback) {
if (!callbacks.get(element)) {
callbacks.set(element, createCallbackManager<TargetCallback>());
}
const callbackManager = callbacks.get(element)!;
callbackManager.addCallback(callback);
}
function removeCallback(element: HTMLElement, callback: TargetCallback) {
const callbackManager = callbacks.get(element);
if (!callbackManager) return;
callbackManager.removeCallback(callback);
if (!callbackManager.hasCallbacks()) {
callbacks.delete(element);
}
}
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
@ -140,7 +160,17 @@ export function useIntersectionObserver({
}, },
); );
controllerRef.current = { observer, callbacks }; function destroy() {
callbacks.clear();
observer.disconnect();
}
controllerRef.current = {
observer,
addCallback,
removeCallback,
destroy,
};
} }
const observe = useLastCallback((target, targetCallback) => { const observe = useLastCallback((target, targetCallback) => {
@ -152,12 +182,12 @@ export function useIntersectionObserver({
controller.observer.observe(target); controller.observer.observe(target);
if (targetCallback) { if (targetCallback) {
controller.callbacks.set(target, targetCallback); controller.addCallback(target, targetCallback);
} }
return () => { return () => {
if (targetCallback) { if (targetCallback) {
controller.callbacks.delete(target); controller.removeCallback(target, targetCallback);
} }
controller.observer.unobserve(target); controller.observer.unobserve(target);