Implement Custom Emojis (#1969)

This commit is contained in:
Alexander Zinchuk 2022-08-31 15:00:45 +02:00
parent 63fac1f6be
commit f88ddafe14
99 changed files with 2024 additions and 659 deletions

View File

@ -1,4 +1,7 @@
import { Api as GramJs } from '../../../lib/gramjs';
import {
ApiMessageEntityTypes,
} from '../../types';
import type {
ApiMessage,
ApiMessageForwardInfo,
@ -32,6 +35,7 @@ import type {
ApiGame,
PhoneCallAction,
ApiWebDocument,
ApiMessageEntityDefault,
} from '../../types';
import {
@ -271,7 +275,7 @@ export function buildMessageContent(
const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported;
if (mtpMessage.message && !hasUnsupportedMedia
&& !content.sticker && !content.poll && !content.contact && !(content.video?.isRound)) {
&& !content.sticker && !content.poll && !content.contact && !(content.video?.isRound)) {
content = {
...content,
text: buildMessageTextContent(mtpMessage.message, mtpMessage.entities),
@ -1211,8 +1215,9 @@ export function buildLocalForwardedMessage(
message: ApiMessage,
serverTimeOffset: number,
scheduledAt?: number,
noAuthor?: boolean,
noCaption?: boolean,
noAuthors?: boolean,
noCaptions?: boolean,
isCurrentUserPremium?: boolean,
): ApiMessage {
const localId = getNextLocalMessageId();
const {
@ -1228,10 +1233,16 @@ export function buildLocalForwardedMessage(
const asIncomingInChatWithSelf = (
toChat.id === currentUserId && (fromChatId !== toChat.id || message.forwardInfo) && !isAudio
);
const shouldHideText = Object.keys(content).length > 1 && content.text && noCaption;
const shouldHideText = Object.keys(content).length > 1 && content.text && noCaptions;
const shouldDropCustomEmoji = !isCurrentUserPremium;
const strippedText = content.text?.entities && shouldDropCustomEmoji ? {
text: content.text.text,
entities: content.text.entities?.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji),
} : content.text;
const updatedContent = {
...content,
text: !shouldHideText ? content.text : undefined,
text: !shouldHideText ? strippedText : undefined,
};
return {
@ -1245,7 +1256,7 @@ export function buildLocalForwardedMessage(
groupedId,
isInAlbum,
// Forward info doesn't get added when users forwards his own messages, also when forwarding audio
...(senderId !== currentUserId && !isAudio && !noAuthor && {
...(senderId !== currentUserId && !isAudio && !noAuthors && {
forwardInfo: {
date: message.date,
isChannelPost: false,
@ -1365,14 +1376,50 @@ function buildNewPoll(poll: ApiNewPoll, localId: number) {
}
export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMessageEntity {
const { className: type, offset, length } = entity;
const {
className: type, offset, length,
} = entity;
if (entity instanceof GramJs.MessageEntityMentionName) {
return {
type: ApiMessageEntityTypes.MentionName,
offset,
length,
userId: buildApiPeerId(entity.userId, 'user'),
};
}
if (entity instanceof GramJs.MessageEntityTextUrl) {
return {
type: ApiMessageEntityTypes.TextUrl,
offset,
length,
url: entity.url,
};
}
if (entity instanceof GramJs.MessageEntityPre) {
return {
type: ApiMessageEntityTypes.Pre,
offset,
length,
language: entity.language,
};
}
if (entity instanceof GramJs.MessageEntityCustomEmoji) {
return {
type: ApiMessageEntityTypes.CustomEmoji,
offset,
length,
documentId: entity.documentId.toString(),
};
}
return {
type,
type: type as `${ApiMessageEntityDefault['type']}`,
offset,
length,
...(entity instanceof GramJs.MessageEntityMentionName && { userId: buildApiPeerId(entity.userId, 'user') }),
...('url' in entity && { url: entity.url }),
...('language' in entity && { language: entity.language }),
};
}

View File

@ -1,8 +1,7 @@
import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiEmojiInteraction, ApiSticker, ApiStickerSet, GramJsEmojiInteraction,
ApiEmojiInteraction, ApiStickerSetInfo, ApiSticker, ApiStickerSet, GramJsEmojiInteraction,
} from '../../types';
import { NO_STICKER_SET_ID } from '../../../config';
import { buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common';
import localDb from '../localDb';
@ -20,6 +19,8 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPrem
.find((attr: any): attr is GramJs.DocumentAttributeSticker => (
attr instanceof GramJs.DocumentAttributeSticker
));
const customEmojiAttribute = document.attributes
.find((attr): attr is GramJs.DocumentAttributeCustomEmoji => attr instanceof GramJs.DocumentAttributeCustomEmoji);
const fileAttribute = (mimeType === LOTTIE_STICKER_MIME_TYPE || mimeType === VIDEO_STICKER_MIME_TYPE)
&& document.attributes
@ -27,12 +28,13 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPrem
attr instanceof GramJs.DocumentAttributeFilename
));
if (!stickerAttribute && !fileAttribute) {
if (!(stickerAttribute || customEmojiAttribute) && !fileAttribute) {
return undefined;
}
const isLottie = mimeType === LOTTIE_STICKER_MIME_TYPE;
const isVideo = mimeType === VIDEO_STICKER_MIME_TYPE;
const isCustomEmoji = Boolean(customEmojiAttribute);
const imageSizeAttribute = document.attributes
.find((attr: any): attr is GramJs.DocumentAttributeImageSize => (
@ -46,10 +48,10 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPrem
const sizeAttribute = imageSizeAttribute || videoSizeAttribute;
const stickerSetInfo = stickerAttribute && stickerAttribute.stickerset instanceof GramJs.InputStickerSetID
? stickerAttribute.stickerset
: undefined;
const emoji = stickerAttribute?.alt;
const stickerOrEmojiAttribute = (stickerAttribute || customEmojiAttribute)!;
const stickerSetInfo = buildApiStickerSetInfo(stickerOrEmojiAttribute?.stickerset);
const emoji = stickerOrEmojiAttribute?.alt;
const isFree = Boolean(customEmojiAttribute?.free ?? true);
const cachedThumb = document.thumbs && document.thumbs.find(
(s): s is GramJs.PhotoCachedSize => s instanceof GramJs.PhotoCachedSize,
@ -82,15 +84,16 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPrem
return {
id: String(document.id),
stickerSetId: stickerSetInfo ? String(stickerSetInfo.id) : NO_STICKER_SET_ID,
stickerSetAccessHash: stickerSetInfo && String(stickerSetInfo.accessHash),
stickerSetInfo,
emoji,
isCustomEmoji,
isLottie,
isVideo,
width,
height,
thumbnail,
hasEffect,
isFree,
};
}
@ -124,6 +127,24 @@ export function buildStickerSet(set: GramJs.StickerSet): ApiStickerSet {
};
}
function buildApiStickerSetInfo(inputSet?: GramJs.TypeInputStickerSet): ApiStickerSetInfo {
if (inputSet instanceof GramJs.InputStickerSetID) {
return {
id: String(inputSet.id),
accessHash: String(inputSet.accessHash),
};
}
if (inputSet instanceof GramJs.InputStickerSetShortName) {
return {
shortName: inputSet.shortName,
};
}
return {
isMissing: true,
};
}
export function buildStickerSetCovered(coveredStickerSet: GramJs.TypeStickerSetCovered): ApiStickerSet {
const stickerSet = buildStickerSet(coveredStickerSet.set);
@ -131,18 +152,20 @@ export function buildStickerSetCovered(coveredStickerSet: GramJs.TypeStickerSetC
: (coveredStickerSet instanceof GramJs.StickerSetMultiCovered) ? coveredStickerSet.covers
: coveredStickerSet.documents;
stickerSet.covers = [];
stickerSetCovers.forEach((cover) => {
if (cover instanceof GramJs.Document) {
const coverSticker = buildStickerFromDocument(cover);
if (coverSticker) {
stickerSet.covers!.push(coverSticker);
localDb.documents[String(cover.id)] = cover;
}
}
});
const stickers = processStickerResult(stickerSetCovers);
return stickerSet;
if (coveredStickerSet instanceof GramJs.StickerSetFullCovered) {
return {
...stickerSet,
stickers,
packs: processStickerPackResult(coveredStickerSet.packs),
};
}
return {
...stickerSet,
covers: stickers,
};
}
export function buildApiEmojiInteraction(json: GramJsEmojiInteraction): ApiEmojiInteraction {
@ -150,3 +173,29 @@ export function buildApiEmojiInteraction(json: GramJsEmojiInteraction): ApiEmoji
timestamps: json.a.map((l) => l.t),
};
}
export function processStickerPackResult(packs: GramJs.StickerPack[]) {
return packs.reduce((acc, { emoticon, documents }) => {
acc[emoticon] = documents.map((documentId) => buildStickerFromDocument(
localDb.documents[String(documentId)],
)).filter<ApiSticker>(Boolean as any);
return acc;
}, {} as Record<string, ApiSticker[]>);
}
export function processStickerResult(stickers: GramJs.TypeDocument[]) {
return stickers
.map((document) => {
if (document instanceof GramJs.Document) {
const sticker = buildStickerFromDocument(document);
if (sticker) {
localDb.documents[String(document.id)] = document;
return sticker;
}
}
return undefined;
})
.filter(Boolean);
}

View File

@ -290,10 +290,10 @@ export function buildMessageFromUpdate(
export function buildMtpMessageEntity(entity: ApiMessageEntity): GramJs.TypeMessageEntity {
const {
type, offset, length, url, userId, language,
type, offset, length,
} = entity;
const user = userId ? localDb.users[userId] : undefined;
const user = 'userId' in entity ? localDb.users[entity.userId] : undefined;
switch (type) {
case ApiMessageEntityTypes.Bold:
@ -307,11 +307,11 @@ export function buildMtpMessageEntity(entity: ApiMessageEntity): GramJs.TypeMess
case ApiMessageEntityTypes.Code:
return new GramJs.MessageEntityCode({ offset, length });
case ApiMessageEntityTypes.Pre:
return new GramJs.MessageEntityPre({ offset, length, language: language || '' });
return new GramJs.MessageEntityPre({ offset, length, language: entity.language || '' });
case ApiMessageEntityTypes.Blockquote:
return new GramJs.MessageEntityBlockquote({ offset, length });
case ApiMessageEntityTypes.TextUrl:
return new GramJs.MessageEntityTextUrl({ offset, length, url: url! });
return new GramJs.MessageEntityTextUrl({ offset, length, url: entity.url });
case ApiMessageEntityTypes.Url:
return new GramJs.MessageEntityUrl({ offset, length });
case ApiMessageEntityTypes.Hashtag:
@ -320,10 +320,12 @@ export function buildMtpMessageEntity(entity: ApiMessageEntity): GramJs.TypeMess
return new GramJs.InputMessageEntityMentionName({
offset,
length,
userId: new GramJs.InputUser({ userId: BigInt(userId!), accessHash: user!.accessHash! }),
userId: new GramJs.InputUser({ userId: BigInt(user!.id), accessHash: user!.accessHash! }),
});
case ApiMessageEntityTypes.Spoiler:
return new GramJs.MessageEntitySpoiler({ offset, length });
case ApiMessageEntityTypes.CustomEmoji:
return new GramJs.MessageEntityCustomEmoji({ offset, length, documentId: BigInt(entity.documentId) });
default:
return new GramJs.MessageEntityUnknown({ offset, length });
}

View File

@ -41,7 +41,7 @@ export {
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers,
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers, fetchPremiumGifts,
removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets,
} from './symbols';
export {

View File

@ -1128,6 +1128,7 @@ export async function forwardMessages({
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
}: {
fromChat: ApiChat;
toChat: ApiChat;
@ -1139,13 +1140,14 @@ export async function forwardMessages({
withMyScore?: boolean;
noAuthors?: boolean;
noCaptions?: boolean;
isCurrentUserPremium?: boolean;
}) {
const messageIds = messages.map(({ id }) => id);
const randomIds = messages.map(generateRandomBigInt);
messages.forEach((message, index) => {
const localMessage = buildLocalForwardedMessage(
toChat, message, serverTimeOffset, scheduledAt, noAuthors, noCaptions,
toChat, message, serverTimeOffset, scheduledAt, noAuthors, noCaptions, isCurrentUserPremium,
);
localDb.localMessages[String(randomIds[index])] = localMessage;

View File

@ -1,9 +1,13 @@
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiSticker, ApiVideo, OnApiUpdate } from '../../types';
import type {
ApiStickerSetInfo, ApiSticker, ApiVideo, OnApiUpdate,
} from '../../types';
import { invokeRequest } from './client';
import { buildStickerFromDocument, buildStickerSet, buildStickerSetCovered } from '../apiBuilders/symbols';
import {
buildStickerSet, buildStickerSetCovered, processStickerPackResult, processStickerResult,
} from '../apiBuilders/symbols';
import { buildInputStickerSet, buildInputDocument, buildInputStickerSetShortName } from '../gramjsBuilders';
import { buildVideoFromDocument } from '../apiBuilders/messages';
import { RECENT_STICKERS_LIMIT } from '../../../config';
@ -16,6 +20,25 @@ export function init(_onUpdate: OnApiUpdate) {
onUpdate = _onUpdate;
}
export async function fetchCustomEmojiSets({ hash = '0' }: { hash?: string }) {
const allStickers = await invokeRequest(new GramJs.messages.GetEmojiStickers({ hash: BigInt(hash) }));
if (!allStickers || allStickers instanceof GramJs.messages.AllStickersNotModified) {
return undefined;
}
allStickers.sets.forEach((stickerSet) => {
if (stickerSet.thumbs?.length) {
localDb.stickerSets[String(stickerSet.id)] = stickerSet;
}
});
return {
hash: String(allStickers.hash),
sets: allStickers.sets.map(buildStickerSet),
};
}
export async function fetchStickerSets({ hash = '0' }: { hash?: string }) {
const allStickers = await invokeRequest(new GramJs.messages.GetAllStickers({ hash: BigInt(hash) }));
@ -113,13 +136,14 @@ export function clearRecentStickers() {
}
export async function fetchStickers(
{ stickerSetShortName, stickerSetId, accessHash }:
{ stickerSetShortName?: string; stickerSetId?: string; accessHash: string },
{ stickerSetInfo }:
{ stickerSetInfo: ApiStickerSetInfo },
) {
if ('isMissing' in stickerSetInfo) return undefined;
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: stickerSetId
? buildInputStickerSet(stickerSetId, accessHash)
: buildInputStickerSetShortName(stickerSetShortName!),
stickerset: 'id' in stickerSetInfo
? buildInputStickerSet(stickerSetInfo.id, stickerSetInfo.accessHash)
: buildInputStickerSetShortName(stickerSetInfo.shortName),
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
@ -133,6 +157,16 @@ export async function fetchStickers(
};
}
export async function fetchCustomEmoji({ documentId }: { documentId: string[] }) {
if (!documentId.length) return undefined;
const result = await invokeRequest(new GramJs.messages.GetCustomEmojiDocuments({
documentId: documentId.map((id) => BigInt(id)),
}));
if (!result) return undefined;
return processStickerResult(result);
}
export async function fetchAnimatedEmojis() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetAnimatedEmoji(),
@ -334,32 +368,6 @@ export async function fetchEmojiKeywords({ language, fromVersion }: {
};
}
function processStickerResult(stickers: GramJs.TypeDocument[]) {
return stickers
.map((document) => {
if (document instanceof GramJs.Document) {
const sticker = buildStickerFromDocument(document);
if (sticker) {
localDb.documents[String(document.id)] = document;
return sticker;
}
}
return undefined;
})
.filter<ApiSticker>(Boolean as any);
}
function processStickerPackResult(packs: GramJs.StickerPack[]) {
return packs.reduce((acc, { emoticon, documents }) => {
acc[emoticon] = documents.map((documentId) => buildStickerFromDocument(
localDb.documents[String(documentId)],
)).filter<ApiSticker>(Boolean as any);
return acc;
}, {} as Record<string, ApiSticker[]>);
}
function processGifResult(gifs: GramJs.TypeDocument[]) {
return gifs
.map((document) => {

View File

@ -866,7 +866,11 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
} else if (update instanceof GramJs.UpdateStickerSets) {
onUpdate({ '@type': 'updateStickerSets' });
} else if (update instanceof GramJs.UpdateStickerSetsOrder) {
onUpdate({ '@type': 'updateStickerSetsOrder', order: update.order.map((n) => n.toString()) });
onUpdate({
'@type': 'updateStickerSetsOrder',
order: update.order.map((n) => n.toString()),
isCustomEmoji: update.emojis,
});
} else if (update instanceof GramJs.UpdateNewStickerSet) {
if (update.stickerset instanceof GramJs.messages.StickerSet) {
const stickerSet = buildStickerSet(update.stickerset.set);

View File

@ -32,9 +32,9 @@ export interface ApiPhoto {
export interface ApiSticker {
id: string;
stickerSetId: string;
stickerSetAccessHash?: string;
stickerSetInfo: ApiStickerSetInfo;
emoji?: string;
isCustomEmoji?: boolean;
isLottie: boolean;
isVideo: boolean;
width?: number;
@ -42,6 +42,7 @@ export interface ApiSticker {
thumbnail?: ApiThumbnail;
isPreloadedGlobally?: boolean;
hasEffect?: boolean;
isFree?: boolean;
}
export interface ApiStickerSet {
@ -61,6 +62,21 @@ export interface ApiStickerSet {
shortName: string;
}
type ApiStickerSetInfoShortName = {
shortName: string;
};
type ApiStickerSetInfoId = {
id: string;
accessHash: string;
};
type ApiStickerSetInfoMissing = {
isMissing: true;
};
export type ApiStickerSetInfo = ApiStickerSetInfoShortName | ApiStickerSetInfoId | ApiStickerSetInfoMissing;
export interface ApiVideo {
id: string;
mimeType: string;
@ -264,14 +280,46 @@ export interface ApiMessageForwardInfo {
adminTitle?: string;
}
export interface ApiMessageEntity {
type: string;
export type ApiMessageEntityDefault = {
type: Exclude<
`${ApiMessageEntityTypes}`,
`${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` |
`${ApiMessageEntityTypes.CustomEmoji}`
>;
offset: number;
length: number;
};
export type ApiMessageEntityPre = {
type: ApiMessageEntityTypes.Pre;
offset: number;
length: number;
userId?: string;
url?: string;
language?: string;
}
};
export type ApiMessageEntityTextUrl = {
type: ApiMessageEntityTypes.TextUrl;
offset: number;
length: number;
url: string;
};
export type ApiMessageEntityMentionName = {
type: ApiMessageEntityTypes.MentionName;
offset: number;
length: number;
userId: string;
};
export type ApiMessageEntityCustomEmoji = {
type: ApiMessageEntityTypes.CustomEmoji;
offset: number;
length: number;
documentId: string;
};
export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl |
ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji;
export enum ApiMessageEntityTypes {
Bold = 'MessageEntityBold',
@ -291,6 +339,7 @@ export enum ApiMessageEntityTypes {
Url = 'MessageEntityUrl',
Underline = 'MessageEntityUnderline',
Spoiler = 'MessageEntitySpoiler',
CustomEmoji = 'MessageEntityCustomEmoji',
Unknown = 'MessageEntityUnknown',
}

View File

@ -366,6 +366,7 @@ export type ApiUpdateStickerSets = {
export type ApiUpdateStickerSetsOrder = {
'@type': 'updateStickerSetsOrder';
order: string[];
isCustomEmoji?: boolean;
};
export type ApiUpdateStickerSet = {

View File

@ -43,6 +43,7 @@ export { default as SponsoredMessageContextMenuContainer }
from '../components/middle/message/SponsoredMessageContextMenuContainer';
// eslint-disable-next-line import/no-cycle
export { default as StickerSetModal } from '../components/common/StickerSetModal';
export { default as CustomEmojiSetsModal } from '../components/common/CustomEmojiSetsModal';
export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer';
export { default as MobileSearch } from '../components/middle/MobileSearch';

View File

@ -124,15 +124,13 @@ const MicrophoneButton: FC<StateProps> = ({
muteMouseDownState.current = 'up';
};
const buttonText = useMemo(() => {
return lang(
hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : (
shouldRaiseHand ? 'VoipMutedByAdmin' : (
noAudioStream ? 'VoipUnmute' : 'VoipTapToMute'
)
),
);
}, [hasRequestedToSpeak, noAudioStream, lang, shouldRaiseHand]);
const buttonText = lang(
hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : (
shouldRaiseHand ? 'VoipMutedByAdmin' : (
noAudioStream ? 'VoipUnmute' : 'VoipTapToMute'
)
),
);
return (
<div className="button-wrapper microphone-wrapper">

View File

@ -0,0 +1,99 @@
import React, {
memo, useEffect, useMemo, useRef,
} from '../../lib/teact/teact';
import type { FC, TeactNode } from '../../lib/teact/teact';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { IS_WEBM_SUPPORTED } from '../../util/environment';
import renderText from './helpers/renderText';
import safePlay from '../../util/safePlay';
import useMedia from '../../hooks/useMedia';
import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useThumbnail from '../../hooks/useThumbnail';
import useCustomEmoji from './hooks/useCustomEmoji';
import AnimatedSticker from './AnimatedSticker';
type OwnProps = {
documentId: string;
children?: TeactNode;
observeIntersection?: ObserveFn;
};
const STICKER_SIZE = 24;
const CustomEmojiInner: FC<OwnProps> = ({
documentId,
children,
observeIntersection,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// An alternative to `withGlobal` to avoid adding numerous global containers
const customEmoji = useCustomEmoji(documentId);
const mediaHash = customEmoji && `sticker${customEmoji.id}`;
const mediaData = useMedia(mediaHash);
const thumbDataUri = useThumbnail(customEmoji);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
useEnsureCustomEmoji(documentId);
useEffect(() => {
if (!customEmoji?.isVideo) return;
const video = ref.current?.querySelector('video');
if (!video || isIntersecting === !video.paused) return;
if (isIntersecting) {
safePlay(video);
} else {
video.pause();
}
}, [customEmoji, isIntersecting]);
const content = useMemo(() => {
if (!customEmoji || (!thumbDataUri && !mediaData)) {
return (children && renderText(children, ['emoji']));
}
if (!mediaData || (customEmoji.isVideo && !IS_WEBM_SUPPORTED)) {
return (
<img src={thumbDataUri} alt={customEmoji.emoji} />
);
}
if (!customEmoji.isVideo && !customEmoji.isLottie) {
return (
<img src={mediaData} alt={customEmoji.emoji} />
);
}
if (customEmoji.isVideo) {
return (
<video
playsInline
muted
autoPlay={isIntersecting}
loop
src={mediaData}
/>
);
}
return (
<AnimatedSticker
size={STICKER_SIZE}
tgsUrl={mediaData}
play={isIntersecting}
isLowPriority
/>
);
}, [children, customEmoji, isIntersecting, mediaData, thumbDataUri]);
return (
<div ref={ref} className="text-entity-custom-emoji emoji">
{content}
</div>
);
};
export default memo(CustomEmojiInner);

View File

@ -0,0 +1,16 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import type { OwnProps } from './CustomEmojiSetsModal';
import { Bundles } from '../../util/moduleLoader';
import useModuleLoader from '../../hooks/useModuleLoader';
const CustomEmojiSetsModalAsync: FC<OwnProps> = (props) => {
const { customEmojiSetIds } = props;
const CustomEmojiSetsModal = useModuleLoader(Bundles.Extra, 'CustomEmojiSetsModal', !customEmojiSetIds);
// eslint-disable-next-line react/jsx-props-no-spreading
return CustomEmojiSetsModal ? <CustomEmojiSetsModal {...props} /> : undefined;
};
export default memo(CustomEmojiSetsModalAsync);

View File

@ -0,0 +1,27 @@
.root {
:global {
.modal-dialog {
max-width: 26.25rem;
}
.multiline-menu-item {
flex-grow: 1;
}
.subtitle {
font-size: 0.875rem;
line-height: 1.5rem;
color: var(--color-text-secondary);
}
}
}
.sets {
position: relative;
width: 100%;
min-height: 19rem;
max-height: 50vh;
overflow-y: auto;
padding: 0 0.25rem;
text-align: left;
}

View File

@ -0,0 +1,78 @@
import React, {
memo, useCallback, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiSticker, ApiStickerSet } from '../../api/types';
import buildClassName from '../../util/buildClassName';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import usePrevious from '../../hooks/usePrevious';
import Modal from '../ui/Modal';
import StickerSetCard from './StickerSetCard';
import styles from './CustomEmojiSetsModal.module.scss';
export type OwnProps = {
customEmojiSetIds?: string[];
onClose: () => void;
};
type StateProps = {
customEmojiSets?: ApiStickerSet[];
};
const CustomEmojiSetsModal: FC<OwnProps & StateProps> = ({
customEmojiSets,
onClose,
}) => {
const { openStickerSet } = getActions();
// eslint-disable-next-line no-null/no-null
const customEmojiModalRef = useRef<HTMLDivElement>(null);
const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: customEmojiModalRef });
const prevCustomEmojiSets = usePrevious(customEmojiSets);
const renderingCustomEmojiSets = customEmojiSets || prevCustomEmojiSets;
const handleSetClick = useCallback((sticker: ApiSticker) => {
openStickerSet({
stickerSetInfo: sticker.stickerSetInfo,
});
}, [openStickerSet]);
return (
<Modal
isOpen={Boolean(customEmojiSets)}
className={styles.root}
onClose={onClose}
hasCloseButton
title="Sets of used emoji"
>
<div className={buildClassName(styles.sets, 'custom-scroll')} ref={customEmojiModalRef}>
{renderingCustomEmojiSets?.map((customEmojiSet) => (
<StickerSetCard
key={customEmojiSet.id}
className={styles.setCard}
stickerSet={customEmojiSet}
onClick={handleSetClick}
observeIntersection={observeIntersectionForCovers}
/>
))}
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { customEmojiSetIds }): StateProps => {
const customEmojiSets = customEmojiSetIds?.map((id) => global.stickers.setsById[id]);
return {
customEmojiSets,
};
},
)(CustomEmojiSetsModal));

View File

@ -103,6 +103,16 @@
height: calc(1.125 * var(--message-text-size, 1rem)) !important;
vertical-align: text-bottom !important;
}
.text-entity-custom-emoji {
// Custom emoji needs to be slightly bigger than normal emoji
--custom-emoji-size: calc(1.125 * var(--message-text-size, 1rem) + 1px);
margin-inline-end: 1px;
& > img {
border-radius: 0;
}
}
}
.embedded-action-message {

View File

@ -13,12 +13,13 @@ import {
import renderText from './helpers/renderText';
import { getPictogramDimensions } from './helpers/mediaDimensions';
import buildClassName from '../../util/buildClassName';
import { renderMessageSummary } from './helpers/renderMessageText';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useMedia from '../../hooks/useMedia';
import useWebpThumbnail from '../../hooks/useWebpThumbnail';
import useThumbnail from '../../hooks/useThumbnail';
import useLang from '../../hooks/useLang';
import { renderMessageSummary } from './helpers/renderMessageText';
import ActionMessage from '../middle/ActionMessage';
@ -56,7 +57,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const mediaBlobUrl = useMedia(message && getMessageMediaHash(message, 'pictogram'), !isIntersecting);
const mediaThumbnail = useWebpThumbnail(message);
const mediaThumbnail = useThumbnail(message);
const isRoundVideo = Boolean(message && getMessageRoundVideo(message));
const lang = useLang();

View File

@ -10,6 +10,17 @@
position: relative;
--premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
&.custom-emoji {
width: 2.5rem;
height: 2.5rem;
margin: 0.3125rem;
}
&.set-expand {
padding: 0;
vertical-align: bottom;
}
.sticker-locked {
position: absolute;
bottom: 0;
@ -52,7 +63,9 @@
}
@media (max-width: 600px) {
margin: 0.25rem;
&, &.custom-emoji {
margin: 0.25rem;
}
}
&.set-button {

View File

@ -20,6 +20,7 @@ import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useThumbnail from '../../hooks/useThumbnail';
import AnimatedSticker from './AnimatedSticker';
import Button from '../ui/Button';
@ -73,7 +74,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const thumbDataUri = sticker.thumbnail ? sticker.thumbnail.dataUri : undefined;
const thumbDataUri = useThumbnail(sticker);
const previewBlobUrl = useMedia(`${localMediaHash}?size=m`, !isIntersecting, ApiMediaFormat.BlobUrl);
const shouldPlay = isIntersecting && !noAnimate;
@ -81,6 +82,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const [isLottieLoaded, markLoaded, unmarkLoaded] = useFlag(Boolean(lottieData));
const canLottiePlay = isLottieLoaded && shouldPlay;
const isVideo = sticker.isVideo && IS_WEBM_SUPPORTED;
const isCustomEmoji = sticker.isCustomEmoji;
const videoBlobUrl = useMedia(isVideo && localMediaHash, !shouldPlay, ApiMediaFormat.BlobUrl);
const canVideoPlay = Boolean(isVideo && videoBlobUrl && shouldPlay);
const isPremiumSticker = sticker.hasEffect;
@ -184,7 +186,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
}, [clickArg, onClick]);
const handleOpenSet = useCallback(() => {
openStickerSet({ sticker });
openStickerSet({ stickerSetInfo: sticker.stickerSetInfo });
}, [openStickerSet, sticker]);
const shouldShowCloseButton = !IS_TOUCH_ENV && onRemoveRecentClick;
@ -192,6 +194,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const fullClassName = buildClassName(
'StickerButton',
onClick && 'interactive',
isCustomEmoji && 'custom-emoji',
stickerSelector,
className,
);
@ -200,7 +203,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const contextMenuItems = useMemo(() => {
const items: ReactNode[] = [];
if (noContextMenu) return items;
if (noContextMenu || isCustomEmoji) return items;
if (onUnfaveClick) {
items.push(
@ -247,7 +250,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
}, [
canViewSet, handleContextFave, handleContextRemoveRecent, handleContextUnfave, handleOpenSet, handleSendQuiet,
handleSendScheduled, isLocked, isSavedMessages, lang, onFaveClick, onRemoveRecentClick, onUnfaveClick, onClick,
noContextMenu,
noContextMenu, isCustomEmoji,
]);
return (

View File

@ -1,4 +1,4 @@
.SettingsStickerSet {
.StickerSetCard {
.settings-item &.ListItem {
margin-bottom: 0.5rem;
}
@ -12,6 +12,10 @@
flex: 0 0 3rem;
}
.install-button {
box-sizing: content-box;
}
img {
max-width: 100%;
max-height: 100%;

View File

@ -0,0 +1,99 @@
import React, { memo, useCallback, useMemo } from '../../lib/teact/teact';
import type { ApiSticker, ApiStickerSet } from '../../api/types';
import type { FC } from '../../lib/teact/teact';
import { STICKER_SIZE_GENERAL_SETTINGS } from '../../config';
import buildClassName from '../../util/buildClassName';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang';
import ListItem from '../ui/ListItem';
import Button from '../ui/Button';
import StickerSetCoverAnimated from '../middle/composer/StickerSetCoverAnimated';
import StickerSetCover from '../middle/composer/StickerSetCover';
import StickerButton from './StickerButton';
import './StickerSetCard.scss';
type OwnProps = {
stickerSet?: ApiStickerSet;
className?: string;
observeIntersection: ObserveFn;
onClick: (value: ApiSticker) => void;
};
const StickerSetCard: FC<OwnProps> = ({
stickerSet,
className,
observeIntersection,
onClick,
}) => {
const lang = useLang();
const firstSticker = stickerSet?.stickers?.[0];
const handleCardClick = useCallback(() => {
if (firstSticker) onClick(firstSticker);
}, [firstSticker, onClick]);
const preview = useMemo(() => {
if (!stickerSet) return undefined;
if (stickerSet.hasThumbnail || !firstSticker) {
return (
<Button
ariaLabel={stickerSet.title}
color="translucent"
isRtl={lang.isRtl}
>
{stickerSet.isLottie ? (
<StickerSetCoverAnimated
size={STICKER_SIZE_GENERAL_SETTINGS}
stickerSet={stickerSet}
observeIntersection={observeIntersection}
/>
) : (
<StickerSetCover
stickerSet={stickerSet}
observeIntersection={observeIntersection}
/>
)}
</Button>
);
} else {
return (
<StickerButton
sticker={firstSticker}
size={STICKER_SIZE_GENERAL_SETTINGS}
title={stickerSet.title}
observeIntersection={observeIntersection}
clickArg={undefined}
noContextMenu
isCurrentUserPremium
/>
);
}
}, [firstSticker, lang.isRtl, observeIntersection, stickerSet]);
if (!stickerSet || !stickerSet.stickers) {
return undefined;
}
return (
<ListItem
narrow
className={buildClassName('StickerSetCard', className)}
inactive={!firstSticker}
onClick={handleCardClick}
>
{preview}
<div className="multiline-menu-item">
<div className="title">{stickerSet.title}</div>
<div className="subtitle">{lang('StickerPack.StickerCount', stickerSet.count, 'i')}</div>
</div>
</ListItem>
);
};
export default memo(StickerSetCard);

View File

@ -1,26 +1,27 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import type { ApiSticker, ApiStickerSet } from '../../api/types';
import { STICKER_SIZE_MODAL } from '../../config';
import { EMOJI_SIZE_MODAL, STICKER_SIZE_MODAL } from '../../config';
import {
selectCanScheduleUntilOnline,
selectChat,
selectCurrentMessageList,
selectIsChatWithSelf, selectIsCurrentUserPremium,
selectIsSetPremium,
selectShouldSchedule,
selectStickerSet,
selectStickerSetByShortName,
} from '../../global/selectors';
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver';
import useLang from '../../hooks/useLang';
import renderText from './helpers/renderText';
import { getAllowedAttachmentOptions, getCanPostInChat } from '../../global/helpers';
import useSchedule from '../../hooks/useSchedule';
import usePrevious from '../../hooks/usePrevious';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
@ -42,6 +43,7 @@ type StateProps = {
canScheduleUntilOnline?: boolean;
shouldSchedule?: boolean;
isSavedMessages?: boolean;
isSetPremium?: boolean;
isCurrentUserPremium?: boolean;
};
@ -56,6 +58,7 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
canScheduleUntilOnline,
shouldSchedule,
isSavedMessages,
isSetPremium,
isCurrentUserPremium,
onClose,
}) => {
@ -63,12 +66,19 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
loadStickers,
toggleStickerSet,
sendMessage,
openPremiumModal,
} = getActions();
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const prevStickerSet = usePrevious(stickerSet);
const renderingStickerSet = stickerSet || prevStickerSet;
const isEmoji = renderingStickerSet?.isEmoji;
const isButtonLocked = !renderingStickerSet?.installedDate && isSetPremium && !isCurrentUserPremium;
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline);
const {
@ -76,20 +86,12 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE, isDisabled: !isOpen });
useEffect(() => {
if (isOpen && !stickerSet?.stickers) {
if (fromSticker) {
const { stickerSetId, stickerSetAccessHash } = fromSticker;
loadStickers({
stickerSetId,
stickerSetAccessHash,
});
} else if (stickerSetShortName) {
loadStickers({
stickerSetShortName,
});
}
if (isOpen && !renderingStickerSet?.stickers) {
loadStickers({
stickerSetInfo: fromSticker ? fromSticker.stickerSetInfo : { shortName: stickerSetShortName! },
});
}
}, [isOpen, fromSticker, loadStickers, stickerSetShortName, stickerSet]);
}, [isOpen, fromSticker, loadStickers, stickerSetShortName, renderingStickerSet]);
const handleSelect = useCallback((sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean) => {
sticker = {
@ -109,11 +111,30 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
}, [onClose, requestCalendar, sendMessage, shouldSchedule]);
const handleButtonClick = useCallback(() => {
if (stickerSet) {
toggleStickerSet({ stickerSetId: stickerSet.id });
if (renderingStickerSet) {
if (isButtonLocked) {
openPremiumModal({ initialSection: 'animated_emoji' });
return;
}
toggleStickerSet({ stickerSetId: renderingStickerSet.id });
onClose();
}
}, [onClose, stickerSet, toggleStickerSet]);
}, [isButtonLocked, onClose, openPremiumModal, renderingStickerSet, toggleStickerSet]);
const renderButtonText = () => {
if (!renderingStickerSet) return lang('Loading');
if (isButtonLocked) {
return lang('EmojiInput.UnlockPack', renderingStickerSet.title);
}
const suffix = isEmoji ? 'Emoji' : 'Sticker';
return lang(
renderingStickerSet.installedDate ? `StickerPack.Remove${suffix}Count` : `StickerPack.Add${suffix}Count`,
renderingStickerSet.count,
'i',
);
};
return (
<Modal
@ -121,17 +142,18 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
isOpen={isOpen}
onClose={onClose}
hasCloseButton
title={stickerSet ? renderText(stickerSet.title, ['emoji', 'links']) : lang('AccDescrStickerSet')}
title={renderingStickerSet
? renderText(renderingStickerSet.title, ['emoji', 'links']) : lang('AccDescrStickerSet')}
>
{stickerSet?.stickers ? (
{renderingStickerSet?.stickers ? (
<>
<div ref={containerRef} className="stickers custom-scroll">
{stickerSet.stickers.map((sticker) => (
{renderingStickerSet.stickers.map((sticker) => (
<StickerButton
sticker={sticker}
size={STICKER_SIZE_MODAL}
size={isEmoji ? EMOJI_SIZE_MODAL : STICKER_SIZE_MODAL}
observeIntersection={observeIntersection}
onClick={canSendStickers ? handleSelect : undefined}
onClick={canSendStickers && !isEmoji ? handleSelect : undefined}
clickArg={sticker}
isSavedMessages={isSavedMessages}
isCurrentUserPremium={isCurrentUserPremium}
@ -142,14 +164,12 @@ const StickerSetModal: FC<OwnProps & StateProps> = ({
<Button
size="smaller"
fluid
color={stickerSet.installedDate ? 'danger' : 'primary'}
color={renderingStickerSet.installedDate ? 'danger' : 'primary'}
isShiny={isButtonLocked}
withPremiumGradient={isButtonLocked}
onClick={handleButtonClick}
>
{lang(
stickerSet.installedDate ? 'StickerPack.RemoveStickerCount' : 'StickerPack.AddStickerCount',
stickerSet.count,
'i',
)}
{renderButtonText()}
</Button>
</div>
</>
@ -172,17 +192,20 @@ export default memo(withGlobal<OwnProps>(
);
const isSavedMessages = Boolean(chatId) && selectIsChatWithSelf(global, chatId);
const stickerSetInfo = fromSticker ? fromSticker.stickerSetInfo
: stickerSetShortName ? { shortName: stickerSetShortName } : undefined;
const stickerSet = stickerSetInfo ? selectStickerSet(global, stickerSetInfo) : undefined;
const isSetPremium = stickerSet && selectIsSetPremium(stickerSet);
return {
canScheduleUntilOnline: Boolean(chatId) && selectCanScheduleUntilOnline(global, chatId),
canSendStickers,
isSavedMessages,
shouldSchedule: selectShouldSchedule(global),
stickerSet: fromSticker
? selectStickerSet(global, fromSticker.stickerSetId)
: stickerSetShortName
? selectStickerSetByShortName(global, stickerSetShortName)
: undefined,
stickerSet,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isSetPremium,
};
},
)(StickerSetModal));

View File

@ -1,6 +1,8 @@
import type { ApiMessage } from '../../../api/types';
import { ApiMessageEntityTypes } from '../../../api/types';
import type { TextPart } from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { LangFn } from '../../../hooks/useLang';
import {
getMessageSummaryDescription,
@ -9,7 +11,6 @@ import {
getMessageText,
TRUNCATED_SUMMARY_LENGTH,
} from '../../../global/helpers';
import type { LangFn } from '../../../hooks/useLang';
import renderText from './renderText';
import { renderTextWithEntities } from './renderTextWithEntities';
import trimText from '../../../util/trimText';
@ -21,6 +22,7 @@ export function renderMessageText(
isSimple?: boolean,
truncateLength?: number,
isProtected?: boolean,
observeIntersection?: ObserveFn,
) {
const { text, entities } = message.content.text || {};
@ -38,6 +40,7 @@ export function renderMessageText(
message.id,
isSimple,
isProtected,
observeIntersection,
);
}
@ -47,11 +50,13 @@ export function renderMessageSummary(
noEmoji = false,
highlight?: string,
truncateLength = TRUNCATED_SUMMARY_LENGTH,
observeIntersection?: ObserveFn,
): TextPart[] {
const { entities } = message.content.text || {};
const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler);
if (!hasSpoilers) {
const hasCustomEmoji = entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji);
if (!hasSpoilers && !hasCustomEmoji) {
const text = trimText(getMessageSummaryText(lang, message, noEmoji), truncateLength);
if (highlight) {
@ -64,11 +69,13 @@ export function renderMessageSummary(
const emoji = !noEmoji && getMessageSummaryEmoji(message);
const emojiWithSpace = emoji ? `${emoji} ` : '';
const text = renderMessageText(message, highlight, undefined, true, truncateLength);
const text = renderMessageText(
message, highlight, undefined, true, truncateLength, undefined, observeIntersection,
);
const description = getMessageSummaryDescription(lang, message, text);
return [
emojiWithSpace,
...renderText(emojiWithSpace),
...(Array.isArray(description) ? description : [description]),
].filter<TextPart>(Boolean);
}

View File

@ -1,12 +1,13 @@
import type { MouseEvent } from 'react';
import React from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { TextPart } from '../../../types';
import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types';
import { ApiMessageEntityTypes } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { TextFilter } from './renderText';
import buildClassName from '../../../util/buildClassName';
import renderText from './renderText';
import { copyTextToClipboard } from '../../../util/clipboard';
import { getTranslation } from '../../../util/langProvider';
@ -14,8 +15,8 @@ import { getTranslation } from '../../../util/langProvider';
import MentionLink from '../../middle/message/MentionLink';
import SafeLink from '../SafeLink';
import Spoiler from '../spoiler/Spoiler';
import CustomEmoji from '../CustomEmoji';
import CodeBlock from '../code/CodeBlock';
import buildClassName from '../../../util/buildClassName';
interface IOrganizedEntity {
entity: ApiMessageEntity;
@ -32,6 +33,7 @@ export function renderTextWithEntities(
messageId?: number,
isSimple?: boolean,
isProtected?: boolean,
observeIntersection?: ObserveFn,
) {
if (!entities || !entities.length) {
return renderMessagePart(text, highlight, shouldRenderHqEmoji, shouldRenderAsHtml, isSimple);
@ -107,7 +109,7 @@ export function renderTextWithEntities(
const newEntity = shouldRenderAsHtml
? processEntityAsHtml(entity, entityContent, nestedEntityContent)
: processEntity(
entity, entityContent, nestedEntityContent, highlight, messageId, isSimple, isProtected,
entity, entityContent, nestedEntityContent, highlight, messageId, isSimple, isProtected, observeIntersection,
);
if (Array.isArray(newEntity)) {
@ -284,6 +286,7 @@ function processEntity(
messageId?: number,
isSimple?: boolean,
isProtected?: boolean,
observeIntersection?: ObserveFn,
) {
const entityText = typeof entityContent === 'string' && entityContent;
const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent;
@ -303,6 +306,14 @@ function processEntity(
if (entity.type === ApiMessageEntityTypes.Spoiler) {
return <Spoiler>{text}</Spoiler>;
}
if (entity.type === ApiMessageEntityTypes.CustomEmoji) {
return (
<CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection}>
{renderNestedMessagePart()}
</CustomEmoji>
);
}
return text;
}
@ -406,6 +417,12 @@ function processEntity(
return <ins>{renderNestedMessagePart()}</ins>;
case ApiMessageEntityTypes.Spoiler:
return <Spoiler messageId={messageId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji:
return (
<CustomEmoji documentId={entity.documentId} observeIntersection={observeIntersection}>
{renderNestedMessagePart()}
</CustomEmoji>
);
default:
return renderNestedMessagePart();
}
@ -469,20 +486,20 @@ function processEntityAsHtml(
}
function getLinkUrl(entityContent: string, entity: ApiMessageEntity) {
const { type, url } = entity;
return type === ApiMessageEntityTypes.TextUrl && url ? url : entityContent;
const { type } = entity;
return type === ApiMessageEntityTypes.TextUrl && entity.url ? entity.url : entityContent;
}
function handleBotCommandClick(e: MouseEvent<HTMLAnchorElement>) {
function handleBotCommandClick(e: React.MouseEvent<HTMLAnchorElement>) {
getActions().sendBotCommand({ command: e.currentTarget.innerText });
}
function handleHashtagClick(e: MouseEvent<HTMLAnchorElement>) {
function handleHashtagClick(e: React.MouseEvent<HTMLAnchorElement>) {
getActions().setLocalTextSearchQuery({ query: e.currentTarget.innerText });
getActions().searchTextMessagesLocal();
}
function handleCodeClick(e: MouseEvent<HTMLElement>) {
function handleCodeClick(e: React.MouseEvent<HTMLElement>) {
copyTextToClipboard(e.currentTarget.innerText);
getActions().showNotification({
message: getTranslation('TextCopied'),

View File

@ -0,0 +1,48 @@
import { useCallback, useEffect, useState } from '../../../lib/teact/teact';
import { addCallback } from '../../../lib/teact/teactn';
import { getGlobal } from '../../../global';
import type { GlobalState } from '../../../global/types';
import type { ApiSticker } from '../../../api/types';
const handlers = new Set<AnyToVoidFunction>();
let prevGlobal: GlobalState | undefined;
addCallback((global: GlobalState) => {
const customEmojiById = global.customEmojis.byId;
if (customEmojiById === prevGlobal?.customEmojis.byId) {
return;
}
for (const handler of handlers) {
handler();
}
prevGlobal = global;
});
export default function useCustomEmoji(documentId: string) {
const [customEmoji, setCustomEmoji] = useState<ApiSticker | undefined>(getGlobal().customEmojis.byId[documentId]);
const handleGlobalChange = useCallback(() => {
setCustomEmoji(getGlobal().customEmojis.byId[documentId]);
}, [documentId]);
useEffect(() => {
if (!documentId) return;
handleGlobalChange();
}, [documentId, handleGlobalChange]);
useEffect(() => {
if (customEmoji) return undefined;
handlers.add(handleGlobalChange);
return () => {
handlers.delete(handleGlobalChange);
};
}, [customEmoji, documentId, handleGlobalChange]);
return customEmoji;
}

View File

@ -145,12 +145,12 @@ const LeftColumn: FC<StateProps> = ({
case SettingsScreens.Privacy:
case SettingsScreens.ActiveSessions:
case SettingsScreens.Language:
case SettingsScreens.Stickers:
case SettingsScreens.Experimental:
setSettingsScreen(SettingsScreens.Main);
return;
case SettingsScreens.GeneralChatBackground:
case SettingsScreens.QuickReaction:
setSettingsScreen(SettingsScreens.General);
return;
case SettingsScreens.GeneralChatBackgroundColor:
@ -279,6 +279,11 @@ const LeftColumn: FC<StateProps> = ({
setContent(LeftColumnContent.ChatList);
setSettingsScreen(SettingsScreens.Main);
return;
case SettingsScreens.QuickReaction:
case SettingsScreens.CustomEmoji:
setSettingsScreen(SettingsScreens.Stickers);
return;
default:
break;
}

View File

@ -173,6 +173,10 @@
vertical-align: -0.125rem;
}
.text-entity-custom-emoji {
--custom-emoji-size: 1.25rem;
}
.icon-play {
position: relative;
display: inline-block;

View File

@ -287,7 +287,7 @@ const Chat: FC<OwnProps & StateProps> = ({
<span className="colon">:</span>
</>
)}
{renderSummary(lang, lastMessage!, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
{renderSummary(lang, lastMessage!, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}
</p>
);
}
@ -369,16 +369,18 @@ const Chat: FC<OwnProps & StateProps> = ({
);
};
function renderSummary(lang: LangFn, message: ApiMessage, blobUrl?: string, isRoundVideo?: boolean) {
function renderSummary(
lang: LangFn, message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean,
) {
if (!blobUrl) {
return renderMessageSummary(lang, message);
return renderMessageSummary(lang, message, undefined, undefined, undefined, observeIntersection);
}
return (
<span className="media-preview">
<img src={blobUrl} alt="" className={buildClassName('media-preview--image', isRoundVideo && 'round')} />
{getMessageVideo(message) && <i className="icon-play" />}
{renderMessageSummary(lang, message, true)}
{renderMessageSummary(lang, message, true, undefined, undefined, observeIntersection)}
</span>
);
}

View File

@ -27,6 +27,8 @@ import SettingsTwoFa from './twoFa/SettingsTwoFa';
import SettingsPrivacyVisibilityExceptionList from './SettingsPrivacyVisibilityExceptionList';
import SettingsQuickReaction from './SettingsQuickReaction';
import SettingsPasscode from './passcode/SettingsPasscode';
import SettingsStickers from './SettingsStickers';
import SettingsCustomEmoji from './SettingsCustomEmoji';
import SettingsExperimental from './SettingsExperimental';
import './Settings.scss';
@ -216,6 +218,7 @@ const Settings: FC<OwnProps> = ({
|| screen === SettingsScreens.GeneralChatBackgroundColor
|| screen === SettingsScreens.GeneralChatBackground
|| screen === SettingsScreens.QuickReaction
|| screen === SettingsScreens.CustomEmoji
|| isPrivacyScreen || isFoldersScreen}
onReset={handleReset}
/>
@ -224,6 +227,10 @@ const Settings: FC<OwnProps> = ({
return (
<SettingsQuickReaction isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.CustomEmoji:
return (
<SettingsCustomEmoji isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.Notifications:
return (
<SettingsNotifications isActive={isScreenActive} onReset={handleReset} />
@ -244,6 +251,10 @@ const Settings: FC<OwnProps> = ({
return (
<SettingsLanguage isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.Stickers:
return (
<SettingsStickers isActive={isScreenActive} onReset={handleReset} onScreenSelect={onScreenSelect} />
);
case SettingsScreens.Experimental:
return (
<SettingsExperimental isActive={isScreenActive} onReset={handleReset} />

View File

@ -0,0 +1,86 @@
import React, {
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker, ApiStickerSet } from '../../../api/types';
import renderText from '../../common/helpers/renderText';
import { pick } from '../../../util/iteratees';
import useHistoryBack from '../../../hooks/useHistoryBack';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import StickerSetCard from '../../common/StickerSetCard';
type OwnProps = {
isActive?: boolean;
onReset: () => void;
};
type StateProps = {
customEmojiSetIds?: string[];
stickerSetsById: Record<string, ApiStickerSet>;
};
const SettingsCustomEmoji: FC<OwnProps & StateProps> = ({
isActive,
customEmojiSetIds,
stickerSetsById,
onReset,
}) => {
const { openStickerSet } = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const stickerSettingsRef = useRef<HTMLDivElement>(null);
const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: stickerSettingsRef });
useHistoryBack({
isActive,
onBack: onReset,
});
const handleStickerSetClick = useCallback((sticker: ApiSticker) => {
openStickerSet({
stickerSetInfo: sticker.stickerSetInfo,
});
}, [openStickerSet]);
const customEmojiSets = useMemo(() => (
customEmojiSetIds && Object.values(pick(stickerSetsById, customEmojiSetIds))
), [customEmojiSetIds, stickerSetsById]);
return (
<div className="settings-content custom-scroll">
{customEmojiSets && (
<div className="settings-item">
<div ref={stickerSettingsRef}>
{customEmojiSets.map((stickerSet: ApiStickerSet) => (
<StickerSetCard
key={stickerSet.id}
stickerSet={stickerSet}
observeIntersection={observeIntersectionForCovers}
onClick={handleStickerSetClick}
/>
))}
</div>
<p className="settings-item-description mt-3" dir="auto">
{renderText(lang('EmojiBotInfo'), ['links'])}
</p>
</div>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global) => {
return {
customEmojiSetIds: global.customEmojis.added.setIds,
stickerSetsById: global.stickers.setsById,
};
},
)(SettingsCustomEmoji));

View File

@ -1,12 +1,11 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
useCallback, memo, useRef, useState,
useCallback, memo,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ISettings, TimeFormat } from '../../../types';
import { SettingsScreens } from '../../../types';
import type { ApiSticker, ApiStickerSet } from '../../../api/types';
import {
getSystemTheme, IS_IOS, IS_MAC_OS, IS_TOUCH_ENV,
@ -14,18 +13,12 @@ import {
import { pick } from '../../../util/iteratees';
import { setTimeFormat } from '../../../util/langProvider';
import useLang from '../../../hooks/useLang';
import useFlag from '../../../hooks/useFlag';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useHistoryBack from '../../../hooks/useHistoryBack';
import ListItem from '../../ui/ListItem';
import RangeSlider from '../../ui/RangeSlider';
import Checkbox from '../../ui/Checkbox';
import type { IRadioOption } from '../../ui/RadioGroup';
import RadioGroup from '../../ui/RadioGroup';
import SettingsStickerSet from './SettingsStickerSet';
import StickerSetModal from '../../common/StickerSetModal.async';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import switchTheme from '../../../util/switchTheme';
import { ANIMATION_LEVEL_MAX } from '../../../config';
@ -40,13 +33,8 @@ type StateProps =
'messageTextSize' |
'animationLevel' |
'messageSendKeyCombo' |
'shouldSuggestStickers' |
'shouldLoopStickers' |
'timeFormat'
)> & {
stickerSetIds?: string[];
stickerSetsById?: Record<string, ApiStickerSet>;
defaultReaction?: string;
theme: ISettings['theme'];
shouldUseSystemTheme: boolean;
};
@ -69,14 +57,9 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
isActive,
onScreenSelect,
onReset,
stickerSetIds,
stickerSetsById,
defaultReaction,
messageTextSize,
animationLevel,
messageSendKeyCombo,
shouldSuggestStickers,
shouldLoopStickers,
timeFormat,
theme,
shouldUseSystemTheme,
@ -85,12 +68,6 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
setSettingOption,
} = getActions();
// eslint-disable-next-line no-null/no-null
const stickerSettingsRef = useRef<HTMLDivElement>(null);
const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: stickerSettingsRef });
const [isModalOpen, openModal, closeModal] = useFlag();
const [sticker, setSticker] = useState<ApiSticker>();
const lang = useLang();
const APPEARANCE_THEME_OPTIONS: IRadioOption[] = [{
@ -149,27 +126,10 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
setTimeFormat(newTimeFormat as TimeFormat);
}, [setSettingOption]);
const handleStickerSetClick = useCallback((value: ApiSticker) => {
setSticker(value);
openModal();
}, [openModal]);
const handleMessageSendComboChange = useCallback((newCombo: string) => {
setSettingOption({ messageSendKeyCombo: newCombo });
}, [setSettingOption]);
const handleSuggestStickersChange = useCallback((newValue: boolean) => {
setSettingOption({ shouldSuggestStickers: newValue });
}, [setSettingOption]);
const handleShouldLoopStickersChange = useCallback((newValue: boolean) => {
setSettingOption({ shouldLoopStickers: newValue });
}, [setSettingOption]);
const stickerSets = stickerSetIds && stickerSetIds.map((id: string) => {
return stickerSetsById?.[id]?.installedDate ? stickerSetsById[id] : false;
}).filter<ApiStickerSet>(Boolean as any);
useHistoryBack({
isActive,
onBack: onReset,
@ -248,50 +208,6 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
/>
</div>
)}
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('AccDescrStickers')}</h4>
{defaultReaction && (
<ListItem
className="SettingsDefaultReaction"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.QuickReaction)}
>
<ReactionStaticEmoji reaction={defaultReaction} />
<div className="title">{lang('DoubleTapSetting')}</div>
</ListItem>
)}
<Checkbox
label={lang('SuggestStickers')}
checked={shouldSuggestStickers}
onCheck={handleSuggestStickersChange}
/>
<Checkbox
label={lang('LoopAnimatedStickers')}
checked={shouldLoopStickers}
onCheck={handleShouldLoopStickersChange}
/>
<div className="mt-4" ref={stickerSettingsRef}>
{stickerSets && stickerSets.map((stickerSet: ApiStickerSet) => (
<SettingsStickerSet
key={stickerSet.id}
stickerSet={stickerSet}
observeIntersection={observeIntersectionForCovers}
onClick={handleStickerSetClick}
/>
))}
</div>
{sticker && (
<StickerSetModal
isOpen={isModalOpen}
fromSticker={sticker}
onClose={closeModal}
/>
)}
</div>
</div>
);
};
@ -305,15 +221,10 @@ export default memo(withGlobal<OwnProps>(
'messageTextSize',
'animationLevel',
'messageSendKeyCombo',
'shouldSuggestStickers',
'shouldLoopStickers',
'isSensitiveEnabled',
'canChangeSensitive',
'timeFormat',
]),
stickerSetIds: global.stickers.added.setIds,
stickerSetsById: global.stickers.setsById,
defaultReaction: global.appConfig?.defaultReaction,
theme,
shouldUseSystemTheme,
};

View File

@ -86,6 +86,8 @@ const SettingsHeader: FC<OwnProps> = ({
return <h3>{lang('General')}</h3>;
case SettingsScreens.QuickReaction:
return <h3>{lang('DoubleTapSetting')}</h3>;
case SettingsScreens.CustomEmoji:
return <h3>{lang('Emoji')}</h3>;
case SettingsScreens.Notifications:
return <h3>{lang('Notifications')}</h3>;
case SettingsScreens.DataStorage:
@ -94,6 +96,8 @@ const SettingsHeader: FC<OwnProps> = ({
return <h3>{lang('PrivacySettings')}</h3>;
case SettingsScreens.Language:
return <h3>{lang('Language')}</h3>;
case SettingsScreens.Stickers:
return <h3>{lang('StickersName')}</h3>;
case SettingsScreens.Experimental:
return <h3>{lang('lng_settings_experimental')}</h3>;

View File

@ -128,6 +128,13 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
{lang('Language')}
<span className="settings-item__current-value">{lang.langName}</span>
</ListItem>
<ListItem
icon="stickers"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Stickers)}
>
{lang('StickersName')}
</ListItem>
{canBuyPremium && (
<ListItem
leftElement={<PremiumIcon withGradient big />}

View File

@ -1,95 +0,0 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import type { ApiSticker, ApiStickerSet } from '../../../api/types';
import { STICKER_SIZE_GENERAL_SETTINGS } from '../../../config';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import useLang from '../../../hooks/useLang';
import ListItem from '../../ui/ListItem';
import Button from '../../ui/Button';
import StickerSetCoverAnimated from '../../middle/composer/StickerSetCoverAnimated';
import StickerSetCover from '../../middle/composer/StickerSetCover';
import StickerButton from '../../common/StickerButton';
import './SettingsStickerSet.scss';
type OwnProps = {
stickerSet?: ApiStickerSet;
observeIntersection: ObserveFn;
onClick: (value: ApiSticker) => void;
};
const SettingsStickerSet: FC<OwnProps> = ({
stickerSet,
observeIntersection,
onClick,
}) => {
const lang = useLang();
if (!stickerSet || !stickerSet.stickers) {
return undefined;
}
const firstSticker = stickerSet.stickers?.[0];
if (stickerSet.hasThumbnail || !firstSticker) {
return (
<ListItem
narrow
className="SettingsStickerSet"
inactive={!firstSticker}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => firstSticker && onClick(firstSticker)}
>
<Button
ariaLabel={stickerSet.title}
color="translucent"
isRtl={lang.isRtl}
>
{stickerSet.isLottie ? (
<StickerSetCoverAnimated
size={STICKER_SIZE_GENERAL_SETTINGS}
stickerSet={stickerSet}
observeIntersection={observeIntersection}
/>
) : (
<StickerSetCover
stickerSet={stickerSet}
observeIntersection={observeIntersection}
/>
)}
</Button>
<div className="multiline-menu-item">
<div className="title">{stickerSet.title}</div>
<div className="subtitle">{lang('StickerPack.StickerCount', stickerSet.count, 'i')}</div>
</div>
</ListItem>
);
} else {
return (
<ListItem
narrow
className="SettingsStickerSet"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onClick(firstSticker)}
>
<StickerButton
sticker={firstSticker}
size={STICKER_SIZE_GENERAL_SETTINGS}
title={stickerSet.title}
observeIntersection={observeIntersection}
clickArg={undefined}
noContextMenu
isCurrentUserPremium
/>
<div className="multiline-menu-item">
<div className="title">{stickerSet.title}</div>
<div className="subtitle">{lang('StickerPack.StickerCount', stickerSet.count, 'i')}</div>
</div>
</ListItem>
);
}
};
export default memo(SettingsStickerSet);

View File

@ -0,0 +1,154 @@
import React, {
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import { SettingsScreens } from '../../../types';
import type { ISettings } from '../../../types';
import type { ApiSticker, ApiStickerSet } from '../../../api/types';
import renderText from '../../common/helpers/renderText';
import { pick } from '../../../util/iteratees';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
import StickerSetCard from '../../common/StickerSetCard';
type OwnProps = {
isActive?: boolean;
onScreenSelect: (screen: SettingsScreens) => void;
onReset: () => void;
};
type StateProps =
Pick<ISettings, (
'shouldSuggestStickers' |
'shouldLoopStickers'
)> & {
addedSetIds?: string[];
customEmojiSetIds?: string[];
stickerSetsById: Record<string, ApiStickerSet>;
defaultReaction?: string;
};
const SettingsStickers: FC<OwnProps & StateProps> = ({
isActive,
addedSetIds,
customEmojiSetIds,
stickerSetsById,
defaultReaction,
shouldSuggestStickers,
shouldLoopStickers,
onReset,
onScreenSelect,
}) => {
const {
setSettingOption,
openStickerSet,
} = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const stickerSettingsRef = useRef<HTMLDivElement>(null);
const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: stickerSettingsRef });
const handleStickerSetClick = useCallback((sticker: ApiSticker) => {
openStickerSet({
stickerSetInfo: sticker.stickerSetInfo,
});
}, [openStickerSet]);
const handleSuggestStickersChange = useCallback((newValue: boolean) => {
setSettingOption({ shouldSuggestStickers: newValue });
}, [setSettingOption]);
const handleShouldLoopStickersChange = useCallback((newValue: boolean) => {
setSettingOption({ shouldLoopStickers: newValue });
}, [setSettingOption]);
const stickerSets = useMemo(() => (
addedSetIds && Object.values(pick(stickerSetsById, addedSetIds))
), [addedSetIds, stickerSetsById]);
useHistoryBack({
isActive,
onBack: onReset,
});
return (
<div className="settings-content custom-scroll">
<div className="settings-item">
<Checkbox
label={lang('SuggestStickers')}
checked={shouldSuggestStickers}
onCheck={handleSuggestStickersChange}
/>
<Checkbox
label={lang('LoopAnimatedStickers')}
checked={shouldLoopStickers}
onCheck={handleShouldLoopStickersChange}
/>
<ListItem
className="mt-4"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.CustomEmoji)}
icon="smile"
>
{lang('StickersList.EmojiItem')}
{customEmojiSetIds && <span className="settings-item__current-value">{customEmojiSetIds.length}</span>}
</ListItem>
{defaultReaction && (
<ListItem
className="SettingsDefaultReaction"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.QuickReaction)}
>
<ReactionStaticEmoji reaction={defaultReaction} />
<div className="title">{lang('DoubleTapSetting')}</div>
</ListItem>
)}
</div>
{stickerSets && (
<div className="settings-item">
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('ChooseStickerMyStickerSets')}
</h4>
<div ref={stickerSettingsRef}>
{stickerSets.map((stickerSet: ApiStickerSet) => (
<StickerSetCard
key={stickerSet.id}
stickerSet={stickerSet}
observeIntersection={observeIntersectionForCovers}
onClick={handleStickerSetClick}
/>
))}
</div>
<p className="settings-item-description mt-3" dir="auto">
{renderText(lang('StickersBotInfo'), ['links'])}
</p>
</div>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
...pick(global.settings.byKey, [
'shouldSuggestStickers',
'shouldLoopStickers',
]),
addedSetIds: global.stickers.added.setIds,
customEmojiSetIds: global.customEmojis.added.setIds,
stickerSetsById: global.stickers.setsById,
defaultReaction: global.appConfig?.defaultReaction,
};
},
)(SettingsStickers));

View File

@ -2,7 +2,7 @@ import type { FC } from '../../lib/teact/teact';
import React, {
useEffect, memo, useCallback, useState, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import { getActions, getGlobal, withGlobal } from '../../global';
import type { LangCode } from '../../types';
import type {
@ -23,12 +23,14 @@ import {
selectIsServiceChatReady,
selectUser,
} from '../../global/selectors';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import buildClassName from '../../util/buildClassName';
import { waitForTransitionEnd } from '../../util/cssAnimationEndListeners';
import { processDeepLink } from '../../util/deeplink';
import windowSize from '../../util/windowSize';
import { getAllNotificationsCount } from '../../util/folderManager';
import { fastRaf } from '../../util/schedulers';
import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps';
import useBackgroundMode from '../../hooks/useBackgroundMode';
import useBeforeUnload from '../../hooks/useBeforeUnload';
import useOnChange from '../../hooks/useOnChange';
@ -36,7 +38,7 @@ import usePreventPinchZoomGesture from '../../hooks/usePreventPinchZoomGesture';
import useForceUpdate from '../../hooks/useForceUpdate';
import { LOCATION_HASH } from '../../hooks/useHistoryBack';
import useShowTransition from '../../hooks/useShowTransition';
import { fastRaf } from '../../util/schedulers';
import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck';
import StickerSetModal from '../common/StickerSetModal.async';
import UnreadCount from '../common/UnreadCounter';
@ -68,6 +70,7 @@ import PaymentModal from '../payment/PaymentModal.async';
import ReceiptModal from '../payment/ReceiptModal.async';
import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async';
import DeleteFolderDialog from './DeleteFolderDialog.async';
import CustomEmojiSetsModal from '../common/CustomEmojiSetsModal.async';
import './Main.scss';
@ -87,6 +90,7 @@ type StateProps = {
isHistoryCalendarOpen: boolean;
shouldSkipHistoryAnimations?: boolean;
openedStickerSetShortName?: string;
openedCustomEmojiSetIds?: string[];
activeGroupCallId?: string;
isServiceChatReady?: boolean;
animationLevel: number;
@ -94,6 +98,7 @@ type StateProps = {
wasTimeFormatSetManually?: boolean;
isPhoneCallActive?: boolean;
addedSetIds?: string[];
addedCustomEmojiIds?: string[];
newContactUserId?: string;
newContactByPhoneNumber?: boolean;
openedGame?: GlobalState['openedGame'];
@ -136,11 +141,13 @@ const Main: FC<StateProps> = ({
shouldSkipHistoryAnimations,
limitReached,
openedStickerSetShortName,
openedCustomEmojiSetIds,
isServiceChatReady,
animationLevel,
language,
wasTimeFormatSetManually,
addedSetIds,
addedCustomEmojiIds,
isPhoneCallActive,
newContactUserId,
newContactByPhoneNumber,
@ -173,11 +180,13 @@ const Main: FC<StateProps> = ({
loadAddedStickers,
loadFavoriteStickers,
ensureTimeFormat,
openStickerSetShortName,
closeStickerSetModal,
closeCustomEmojiSets,
checkVersionNotification,
loadAppConfig,
loadAttachMenuBots,
loadContactList,
loadCustomEmojis,
closePaymentModal,
clearReceipt,
} = getActions();
@ -226,17 +235,29 @@ const Main: FC<StateProps> = ({
}
}, [language, lastSyncTime, loadCountryList, loadEmojiKeywords]);
// Re-fetch cached saved emoji for `localDb`
useEffectWithPrevDeps(([prevLastSyncTime]) => {
if (!prevLastSyncTime && lastSyncTime) {
loadCustomEmojis({
ids: Object.keys(getGlobal().customEmojis.byId),
ignoreCache: true,
});
}
}, [lastSyncTime] as const);
// Sticker sets
useEffect(() => {
if (lastSyncTime) {
if (!addedSetIds) {
if (!addedSetIds || !addedCustomEmojiIds) {
loadStickerSets();
loadFavoriteStickers();
} else {
}
if (addedSetIds && addedCustomEmojiIds) {
loadAddedStickers();
}
}
}, [lastSyncTime, addedSetIds, loadStickerSets, loadFavoriteStickers, loadAddedStickers]);
}, [lastSyncTime, addedSetIds, loadStickerSets, loadFavoriteStickers, loadAddedStickers, addedCustomEmojiIds]);
// Check version when service chat is ready
useEffect(() => {
@ -378,8 +399,12 @@ const Main: FC<StateProps> = ({
}, [updateIsOnline]);
const handleStickerSetModalClose = useCallback(() => {
openStickerSetShortName({ stickerSetShortName: undefined });
}, [openStickerSetShortName]);
closeStickerSetModal();
}, [closeStickerSetModal]);
const handleCustomEmojiSetsModalClose = useCallback(() => {
closeCustomEmojiSets();
}, [closeCustomEmojiSets]);
// Online status and browser tab indicators
useBackgroundMode(handleBlur, handleFocus);
@ -404,6 +429,10 @@ const Main: FC<StateProps> = ({
onClose={handleStickerSetModalClose}
stickerSetShortName={openedStickerSetShortName}
/>
<CustomEmojiSetsModal
customEmojiSetIds={openedCustomEmojiSetIds}
onClose={handleCustomEmojiSetsModalClose}
/>
{activeGroupCallId && <GroupCall groupCallId={activeGroupCallId} />}
<ActiveCallHeader isActive={Boolean(activeGroupCallId || isPhoneCallActive)} />
<NewContactModal
@ -486,6 +515,7 @@ export default memo(withGlobal(
isHistoryCalendarOpen: Boolean(global.historyCalendarSelectedAt),
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
openedStickerSetShortName: global.openedStickerSetShortName,
openedCustomEmojiSetIds: global.openedCustomEmojiSetIds,
isServiceChatReady: selectIsServiceChatReady(global),
activeGroupCallId: global.groupCalls.activeGroupCallId,
animationLevel,
@ -493,6 +523,7 @@ export default memo(withGlobal(
wasTimeFormatSetManually,
isPhoneCallActive: Boolean(global.phoneCall),
addedSetIds: global.stickers.added.setIds,
addedCustomEmojiIds: global.customEmojis.added.setIds,
newContactUserId: global.newContact?.userId,
newContactByPhoneNumber: global.newContact?.isByPhoneNumber,
openedGame,

View File

@ -8,10 +8,6 @@
height: 3rem;
}
.button-premium {
background: var(--premium-gradient);
}
.button-content {
height: 100%;
display: flex;

View File

@ -309,8 +309,9 @@ const PremiumFeatureModal: FC<OwnProps> = ({
onSelectSlide={handleSelectSlide}
/>
<Button
className={buildClassName(styles.button, !isPremium && styles.buttonPremium)}
className={buildClassName(styles.button)}
isShiny={!isPremium}
withPremiumGradient={!isPremium}
onClick={isPremium ? onBack : handleClick}
>
{isPremium

View File

@ -25,7 +25,6 @@
.button {
font-weight: 600;
background: var(--premium-gradient);
font-size: 1rem;
height: 3rem;
}

View File

@ -283,7 +283,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
{!isPremium && (
<div className={styles.footer}>
{/* eslint-disable-next-line react/jsx-no-bind */}
<Button className={styles.button} isShiny onClick={handleClick}>
<Button className={styles.button} isShiny withPremiumGradient onClick={handleClick}>
{lang('SubscribeToPremium', formatCurrency(Number(promo.monthlyAmount), promo.currency, lang.code))}
</Button>
</div>

View File

@ -67,12 +67,16 @@
max-height: 2.75rem;
}
.emoji {
.emoji:not(.text-entity-custom-emoji) {
width: 0.9375rem;
height: 0.9375rem;
vertical-align: -2px;
}
.text-entity-custom-emoji {
--custom-emoji-size: 1.25rem;
}
&.multiline {
&::before {
content: "";

View File

@ -11,7 +11,7 @@ import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/environment';
import useMedia from '../../hooks/useMedia';
import useWebpThumbnail from '../../hooks/useWebpThumbnail';
import useThumbnail from '../../hooks/useThumbnail';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
@ -36,7 +36,7 @@ const HeaderPinnedMessage: FC<OwnProps> = ({
}) => {
const { clickBotInlineButton } = getActions();
const lang = useLang();
const mediaThumbnail = useWebpThumbnail(message);
const mediaThumbnail = useThumbnail(message);
const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram'));
const text = renderMessageSummary(lang, message, Boolean(mediaThumbnail));

View File

@ -132,7 +132,7 @@ const MessageListContent: FC<OwnProps> = ({
&& isActionMessage(senderGroup[0])
&& !senderGroup[0].content.action?.phoneCall
) {
const message = senderGroup[0];
const message = senderGroup[0]!;
const isLastInList = (
senderGroupIndex === senderGroupsArray.length - 1
&& dateGroupIndex === dateGroupsArray.length - 1

View File

@ -520,6 +520,10 @@
body.is-ios & {
font-size: 0.9375rem;
}
.text-entity-custom-emoji {
--custom-emoji-size: 1.125rem;
}
}
}

View File

@ -1,8 +1,8 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAttachment, ApiChatMember } from '../../../api/types';
import {
@ -48,6 +48,7 @@ export type OwnProps = {
baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>;
shouldSchedule?: boolean;
captionLimit: number;
addRecentEmoji: AnyToVoidFunction;
onCaptionUpdate: (html: string) => void;
onSend: () => void;
@ -55,7 +56,6 @@ export type OwnProps = {
onClear: () => void;
onSendSilent: () => void;
onSendScheduled: () => void;
captionLimit: number;
};
const DROP_LEAVE_TIMEOUT_MS = 150;
@ -105,6 +105,7 @@ const AttachmentModal: FC<OwnProps> = ({
undefined,
currentUserId,
);
const {
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji,
} = useEmojiTooltip(

View File

@ -68,7 +68,7 @@ import focusEditableElement from '../../../util/focusEditableElement';
import parseMessageInput from '../../../util/parseMessageInput';
import buildAttachment from './helpers/buildAttachment';
import renderText from '../../common/helpers/renderText';
import insertHtmlInSelection from '../../../util/insertHtmlInSelection';
import { insertHtmlInSelection } from '../../../util/selection';
import deleteLastCharacterOutsideSelection from '../../../util/deleteLastCharacterOutsideSelection';
import buildClassName from '../../../util/buildClassName';
import windowSize from '../../../util/windowSize';
@ -448,7 +448,7 @@ const Composer: FC<OwnProps & StateProps> = ({
!isReady,
);
const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const insertHtmlAndUpdateCursor = useCallback((newHtml: string, inputId: string = EDITABLE_INPUT_ID) => {
const selection = window.getSelection()!;
let messageInput: HTMLDivElement;
if (inputId === EDITABLE_INPUT_ID) {
@ -456,9 +456,6 @@ const Composer: FC<OwnProps & StateProps> = ({
} else {
messageInput = document.getElementById(inputId) as HTMLDivElement;
}
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
.join('')
.replace(/\u200b+/g, '\u200b');
if (selection.rangeCount) {
const selectionRange = selection.getRangeAt(0);
@ -477,6 +474,13 @@ const Composer: FC<OwnProps & StateProps> = ({
});
}, [htmlRef]);
const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
.join('')
.replace(/\u200b+/g, '\u200b');
insertHtmlAndUpdateCursor(newHtml, inputId);
}, [insertHtmlAndUpdateCursor]);
const removeSymbol = useCallback(() => {
const selection = window.getSelection()!;
@ -1094,8 +1098,8 @@ const Composer: FC<OwnProps & StateProps> = ({
isDisabled={Boolean(activeVoiceRecording)}
/>
)}
{isChatWithBot && isBotMenuButtonCommands && botCommands !== false && !activeVoiceRecording
&& !editingMessage && (
{(isChatWithBot && isBotMenuButtonCommands
&& botCommands !== false && !activeVoiceRecording && !editingMessage) && (
<ResponsiveHoverButton
className={buildClassName('bot-commands', isBotCommandMenuOpen && 'activated')}
round
@ -1318,8 +1322,8 @@ export default memo(withGlobal<OwnProps>(
const requestedText = selectRequestedText(global, chatId);
const currentMessageList = selectCurrentMessageList(global);
const isForCurrentMessageList = chatId === currentMessageList?.chatId
&& threadId === currentMessageList?.threadId
&& messageListType === currentMessageList?.type;
&& threadId === currentMessageList?.threadId
&& messageListType === currentMessageList?.type;
const user = selectUser(global, chatId);
const canSendVoiceByPrivacy = (user && !user.fullInfo?.noVoiceMessages) ?? true;

View File

@ -5,6 +5,7 @@ import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
import { ApiMessageEntityTypes } from '../../../api/types';
import {
selectChat,
@ -18,6 +19,7 @@ import {
selectEditingScheduledId,
selectEditingMessage,
selectIsChatWithSelf,
selectIsCurrentUserPremium,
} from '../../../global/selectors';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import buildClassName from '../../../util/buildClassName';
@ -47,6 +49,7 @@ type StateProps = {
noAuthors?: boolean;
noCaptions?: boolean;
forwardsHaveCaptions?: boolean;
isCurrentUserPremium?: boolean;
};
type OwnProps = {
@ -65,6 +68,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
noAuthors,
noCaptions,
forwardsHaveCaptions,
isCurrentUserPremium,
onClear,
}) => {
const {
@ -159,6 +163,23 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
? lang('ForwardedMessageCount', forwardedMessagesCount)
: undefined;
const strippedMessage = useMemo(() => {
const textEntities = message?.content.text?.entities;
if (!message || !isForwarding || !textEntities?.length || !noAuthors || isCurrentUserPremium) return message;
const filteredEntities = textEntities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji);
return {
...message,
content: {
...message.content,
text: {
text: message.content.text!.text,
entities: filteredEntities,
},
},
};
}, [isCurrentUserPremium, isForwarding, message, noAuthors]);
if (!shouldRender) {
return undefined;
}
@ -171,7 +192,7 @@ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
</div>
<EmbeddedMessage
className="inside-input"
message={message}
message={strippedMessage}
sender={!noAuthors ? sender : undefined}
customText={customText}
title={editingId ? lang('EditMessage') : noAuthors ? lang('HiddenSendersNameDescription') : undefined}
@ -315,6 +336,7 @@ export default memo(withGlobal<OwnProps>(
noAuthors,
noCaptions,
forwardsHaveCaptions,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
};
},
)(ComposerEmbeddedMessage));

View File

@ -4,7 +4,7 @@
justify-content: center;
width: 2.5rem;
height: 2.5rem;
margin: 0.125rem;
margin: 0.3125rem;
border-radius: var(--border-radius-messages-small);
cursor: pointer;
font-size: 1.75rem;
@ -13,6 +13,10 @@
background-color: transparent;
transition: background-color 0.15s ease;
@media (max-width: 600px) {
margin: 0.25rem;
}
.mac-os-fix & {
line-height: inherit;
}

View File

@ -13,8 +13,8 @@ import useLang from '../../../hooks/useLang';
import EmojiButton from './EmojiButton';
const EMOJIS_PER_ROW_ON_DESKTOP = 9;
const EMOJI_MARGIN = 4;
const EMOJIS_PER_ROW_ON_DESKTOP = 8;
const EMOJI_MARGIN = 10;
const MOBILE_CONTAINER_PADDING = 8;
const EMOJI_SIZE = 40;

View File

@ -4,7 +4,7 @@
&-main {
height: calc(100% - 3rem);
overflow-y: auto;
padding: 0.5rem;
padding: 0.4375rem;
@media (max-width: 600px) {
padding: 0.5rem 0.25rem;

View File

@ -18,8 +18,8 @@ import {
uncompressEmoji,
} from '../../../util/emoji';
import fastSmoothScroll from '../../../util/fastSmoothScroll';
import buildClassName from '../../../util/buildClassName';
import { pick } from '../../../util/iteratees';
import buildClassName from '../../../util/buildClassName';
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
@ -38,6 +38,7 @@ type OwnProps = {
};
type StateProps = Pick<GlobalState, 'recentEmojis'>;
type EmojiCategoryData = { id: string; name: string; emojis: string[] };
const ICONS_BY_CATEGORY: Record<string, string> = {
@ -66,7 +67,9 @@ let emojiRawData: EmojiRawData;
let emojiData: EmojiData;
const EmojiPicker: FC<OwnProps & StateProps> = ({
className, onEmojiSelect, recentEmojis,
className,
recentEmojis,
onEmojiSelect,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);

View File

@ -20,7 +20,7 @@ import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import fastSmoothScroll from '../../../util/fastSmoothScroll';
import buildClassName from '../../../util/buildClassName';
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
import { pickTruthy } from '../../../util/iteratees';
import { pickTruthy, uniqueByField } from '../../../util/iteratees';
import { selectChat, selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors';
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
@ -53,6 +53,7 @@ type StateProps = {
chat?: ApiChat;
recentStickers: ApiSticker[];
favoriteStickers: ApiSticker[];
premiumStickers: ApiSticker[];
stickerSetsById: Record<string, ApiStickerSet>;
addedSetIds?: string[];
shouldPlay?: boolean;
@ -74,6 +75,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
canSendStickers,
recentStickers,
favoriteStickers,
premiumStickers,
addedSetIds,
stickerSetsById,
shouldPlay,
@ -155,17 +157,19 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
}
if (isCurrentUserPremium) {
const premiumStickers = existingAddedSetIds
const addedPremiumStickers = existingAddedSetIds
.map((l) => l.stickers?.filter((sticker) => sticker.hasEffect))
.flat()
.filter(Boolean);
if (premiumStickers.length) {
const totalPremiumStickers = uniqueByField([...addedPremiumStickers, ...premiumStickers], 'id');
if (totalPremiumStickers.length) {
defaultSets.push({
id: PREMIUM_STICKER_SET_ID,
title: lang('PremiumStickers'),
stickers: premiumStickers,
count: premiumStickers.length,
stickers: totalPremiumStickers,
count: totalPremiumStickers.length,
});
}
}
@ -187,7 +191,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
...existingAddedSetIds,
];
}, [
addedSetIds, favoriteStickers, isCurrentUserPremium, recentStickers, chat, lang, stickerSetsById,
addedSetIds, stickerSetsById, favoriteStickers, recentStickers, isCurrentUserPremium, chat, lang, premiumStickers,
]);
const noPopulatedSets = useMemo(() => (
@ -274,7 +278,7 @@ const StickerPicker: FC<OwnProps & StateProps> = ({
onClick={() => selectStickerSet(index)}
>
{stickerSet.id === PREMIUM_STICKER_SET_ID ? (
<PremiumIcon withGradient />
<PremiumIcon withGradient big />
) : stickerSet.id === RECENT_SYMBOL_SET_ID ? (
<i className="icon-recent" />
) : stickerSet.id === FAVORITE_SYMBOL_SET_ID ? (
@ -370,6 +374,7 @@ export default memo(withGlobal<OwnProps>(
added,
recent,
favorite,
premiumSet,
} = global.stickers;
const isSavedMessages = selectIsChatWithSelf(global, chatId);
@ -379,6 +384,7 @@ export default memo(withGlobal<OwnProps>(
chat,
recentStickers: recent.stickers,
favoriteStickers: favorite.stickers,
premiumStickers: premiumSet.stickers,
stickerSetsById: setsById,
addedSetIds: added.setIds,
shouldPlay: global.settings.byKey.shouldLoopStickers,

View File

@ -1,15 +1,17 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useMemo, useRef,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
import type { StickerSetOrRecent } from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { useOnIntersect } from '../../../hooks/useIntersectionObserver';
import { FAVORITE_SYMBOL_SET_ID, RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER } from '../../../config';
import {
EMOJI_SIZE_PICKER, FAVORITE_SYMBOL_SET_ID, RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER,
} from '../../../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import windowSize from '../../../util/windowSize';
import buildClassName from '../../../util/buildClassName';
@ -20,6 +22,7 @@ import useMediaTransition from '../../../hooks/useMediaTransition';
import StickerButton from '../../common/StickerButton';
import ConfirmDialog from '../../ui/ConfirmDialog';
import Button from '../../ui/Button';
type OwnProps = {
stickerSet: StickerSetOrRecent;
@ -29,15 +32,17 @@ type OwnProps = {
favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean;
observeIntersection: ObserveFn;
onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onStickerUnfave: (sticker: ApiSticker) => void;
onStickerFave: (sticker: ApiSticker) => void;
onStickerRemoveRecent: (sticker: ApiSticker) => void;
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onStickerUnfave?: (sticker: ApiSticker) => void;
onStickerFave?: (sticker: ApiSticker) => void;
onStickerRemoveRecent?: (sticker: ApiSticker) => void;
isCurrentUserPremium?: boolean;
};
const STICKERS_PER_ROW_ON_DESKTOP = 5;
const EMOJI_PER_ROW_ON_DESKTOP = 8;
const STICKER_MARGIN = IS_SINGLE_COLUMN_LAYOUT ? 8 : 16;
const EMOJI_MARGIN = IS_SINGLE_COLUMN_LAYOUT ? 8 : 10;
const MOBILE_CONTAINER_PADDING = 8;
const StickerSet: FC<OwnProps> = ({
@ -58,21 +63,35 @@ const StickerSet: FC<OwnProps> = ({
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const [isExpanded, expand] = useFlag(!stickerSet.isEmoji);
const lang = useLang();
useOnIntersect(ref, observeIntersection);
const transitionClassNames = useMediaTransition(shouldRender);
const isEmoji = stickerSet.isEmoji;
const handleClearRecent = useCallback(() => {
clearRecentStickers();
closeConfirmModal();
}, [clearRecentStickers, closeConfirmModal]);
const isLocked = !isSavedMessages && isEmoji && !isCurrentUserPremium
&& stickerSet.stickers?.some((l) => !l.isFree);
const itemSize = isEmoji ? EMOJI_SIZE_PICKER : STICKER_SIZE_PICKER;
const itemsPerRow = isEmoji ? EMOJI_PER_ROW_ON_DESKTOP : STICKERS_PER_ROW_ON_DESKTOP;
const margin = isEmoji ? EMOJI_MARGIN : STICKER_MARGIN;
const stickersPerRow = IS_SINGLE_COLUMN_LAYOUT
? Math.floor((windowSize.get().width - MOBILE_CONTAINER_PADDING) / (STICKER_SIZE_PICKER + STICKER_MARGIN))
: STICKERS_PER_ROW_ON_DESKTOP;
const height = Math.ceil(stickerSet.count / stickersPerRow) * (STICKER_SIZE_PICKER + STICKER_MARGIN);
? Math.floor((windowSize.get().width - MOBILE_CONTAINER_PADDING) / (itemSize + margin))
: itemsPerRow;
const shouldCutSet = isEmoji && !isExpanded && !stickerSet.installedDate && stickerSet.id !== RECENT_SYMBOL_SET_ID;
const itemsBeforeCutout = shouldCutSet ? stickersPerRow * 3 : Infinity;
const height = Math.ceil((
!shouldCutSet ? stickerSet.count : Math.min(itemsBeforeCutout, stickerSet.count))
/ stickersPerRow) * (itemSize + margin);
const favoriteStickerIdsSet = useMemo(() => (
favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined
@ -85,10 +104,15 @@ const StickerSet: FC<OwnProps> = ({
ref={ref}
key={stickerSet.id}
id={`sticker-set-${index}`}
className="symbol-set"
className={
buildClassName('symbol-set', isLocked && 'symbol-set-locked')
}
>
<div className="symbol-set-header">
<p className="symbol-set-name">{stickerSet.title}</p>
<p className="symbol-set-name">
{isLocked && <i className="symbol-set-locked-icon icon-lock-badge" />}
{stickerSet.title}
</p>
{isRecent && (
<i className="symbol-set-remove icon-close" onClick={openConfirmModal} />
)}
@ -97,29 +121,36 @@ const StickerSet: FC<OwnProps> = ({
className={buildClassName('symbol-set-container', transitionClassNames)}
style={`height: ${height}px;`}
>
{shouldRender && stickerSet.stickers && stickerSet.stickers.map((sticker) => (
<StickerButton
key={sticker.id}
sticker={sticker}
size={STICKER_SIZE_PICKER}
observeIntersection={observeIntersection}
noAnimate={!loadAndPlay}
onClick={onStickerSelect}
clickArg={sticker}
onUnfaveClick={stickerSet.id === FAVORITE_SYMBOL_SET_ID && favoriteStickerIdsSet?.has(sticker.id)
? onStickerUnfave : undefined}
onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined}
onRemoveRecentClick={isRecent ? onStickerRemoveRecent : undefined}
isSavedMessages={isSavedMessages}
canViewSet
isCurrentUserPremium={isCurrentUserPremium}
/>
))}
{shouldRender && stickerSet.stickers && stickerSet.stickers
.slice(0, !isExpanded ? (itemsBeforeCutout - 1) : stickerSet.stickers.length)
.map((sticker) => (
<StickerButton
key={sticker.id}
sticker={sticker}
size={itemSize}
observeIntersection={observeIntersection}
noAnimate={!loadAndPlay}
onClick={onStickerSelect}
clickArg={sticker}
onUnfaveClick={stickerSet.id === FAVORITE_SYMBOL_SET_ID && favoriteStickerIdsSet?.has(sticker.id)
? onStickerUnfave : undefined}
onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined}
onRemoveRecentClick={isRecent ? onStickerRemoveRecent : undefined}
isSavedMessages={isSavedMessages}
canViewSet
isCurrentUserPremium={isCurrentUserPremium}
/>
))}
{!isExpanded && stickerSet.count > itemsBeforeCutout - 1 && (
<Button className="StickerButton custom-emoji set-expand" round color="translucent" onClick={expand}>
+{stickerSet.count - itemsBeforeCutout + 1}
</Button>
)}
</div>
{isRecent && (
<ConfirmDialog
text={lang('ClearRecentEmoji')}
text={lang('ClearRecentStickersAlertMessage')}
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirmHandler={handleClearRecent}

View File

@ -80,7 +80,7 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
sticker={sticker}
size={STICKER_SIZE_PICKER}
observeIntersection={observeIntersection}
onClick={onStickerSelect}
onClick={isOpen ? onStickerSelect : undefined}
clickArg={sticker}
isSavedMessages={isSavedMessages}
canViewSet

View File

@ -165,11 +165,24 @@
.symbol-set {
margin-bottom: 1rem;
position: relative;
display: flex;
flex-direction: column;
&.symbol-set-locked::before {
content: "";
display: block;
position: absolute;
inset: -0.25rem;
top: 0.75rem;
background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'><rect width='100%' height='100%' style='stroke: rgba(112, 117, 121, 0.7); width: calc(100% - 4px); height: calc(100% - 4px);' fill='none' stroke-dashoffset='5' stroke-width='2' stroke-dasharray='8' stroke-linecap='round' rx='8' ry='8' x='2' y='2' /></svg>");
}
&-header {
display: flex;
align-items: center;
color: rgba(var(--color-text-secondary-rgb), 0.75);
align-self: center;
}
&-name {
@ -177,16 +190,24 @@
line-height: 1.6875rem;
font-weight: 500;
margin: 0;
padding-left: 0.5rem;
padding: 0 0.5rem;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: initial;
text-align: center;
unicode-bidi: plaintext;
flex-grow: 1;
z-index: 1;
background-color: var(--color-background);
}
&-locked-icon {
margin-right: 0.25rem;
}
&-remove {
right: 0;
position: absolute;
font-size: 1rem;
cursor: pointer;
}

View File

@ -1,14 +1,17 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useLayoutEffect, useRef, useState,
} from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker, ApiVideo } from '../../../api/types';
import type { GlobalActions } from '../../../global/types';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../../util/environment';
import { fastRaf } from '../../../util/schedulers';
import buildClassName from '../../../util/buildClassName';
import { selectIsCurrentUserPremium } from '../../../global/selectors';
import useShowTransition from '../../../hooks/useShowTransition';
import useMouseInside from '../../../hooks/useMouseInside';
import useLang from '../../../hooks/useLang';
@ -41,11 +44,12 @@ export type OwnProps = {
onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void;
onRemoveSymbol: () => void;
onSearchOpen: (type: 'stickers' | 'gifs') => void;
addRecentEmoji: AnyToVoidFunction;
addRecentEmoji: GlobalActions['addRecentEmoji'];
};
type StateProps = {
isLeftColumnShown: boolean;
isCurrentUserPremium?: boolean;
};
let isActivated = false;
@ -57,6 +61,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
canSendStickers,
canSendGifs,
isLeftColumnShown,
isCurrentUserPremium,
onLoad,
onClose,
onEmojiSelect,
@ -66,6 +71,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onSearchOpen,
addRecentEmoji,
}) => {
const { loadPremiumSetStickers } = getActions();
const [activeTab, setActiveTab] = useState<number>(0);
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
@ -80,6 +86,12 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onLoad();
}, [onLoad]);
useEffect(() => {
if (isCurrentUserPremium) {
loadPremiumSetStickers();
}
}, [isCurrentUserPremium, loadPremiumSetStickers]);
useLayoutEffect(() => {
if (!IS_SINGLE_COLUMN_LAYOUT) {
return undefined;
@ -105,7 +117,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
const recentEmojisRef = useRef(recentEmojis);
recentEmojisRef.current = recentEmojis;
useEffect(() => {
if (!recentEmojisRef.current.length) {
if (!recentEmojisRef.current.length || isOpen) {
return;
}
@ -114,12 +126,10 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
});
setRecentEmojis([]);
}, [isOpen, activeTab, addRecentEmoji]);
}, [isOpen, addRecentEmoji]);
const handleEmojiSelect = useCallback((emoji: string, name: string) => {
setRecentEmojis((emojis) => {
return [...emojis, name];
});
setRecentEmojis((emojis) => [...emojis, name]);
onEmojiSelect(emoji);
}, [onEmojiSelect]);
@ -177,7 +187,7 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
<>
<div className="SymbolMenu-main" onClick={stopPropagation}>
{isActivated && (
<Transition name="slide" activeKey={activeTab} renderCount={SYMBOL_MENU_TAB_TITLES.length}>
<Transition name="slide" activeKey={activeTab} renderCount={Object.values(SYMBOL_MENU_TAB_TITLES).length}>
{renderContent}
</Transition>
)}
@ -246,6 +256,7 @@ export default memo(withGlobal<OwnProps>(
(global): StateProps => {
return {
isLeftColumnShown: global.isLeftColumnShown,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
};
},
)(SymbolMenu));

View File

@ -18,10 +18,11 @@ export enum SymbolMenuTabs {
'GIFs',
}
// Getting enum string values for display in Tabs.
// See: https://www.typescriptlang.org/docs/handbook/enums.html#reverse-mappings
export const SYMBOL_MENU_TAB_TITLES = Object.values(SymbolMenuTabs)
.filter((value): value is string => typeof value === 'string');
export const SYMBOL_MENU_TAB_TITLES: Record<SymbolMenuTabs, string> = {
[SymbolMenuTabs.Emoji]: 'Emoji',
[SymbolMenuTabs.Stickers]: 'AccDescrStickers',
[SymbolMenuTabs.GIFs]: 'GifsTab',
};
const SYMBOL_MENU_TAB_ICONS = {
[SymbolMenuTabs.Emoji]: 'icon-smile',
@ -40,7 +41,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
className={`symbol-tab-button ${activeTab === tab ? 'activated' : ''}`}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onSwitchTab(tab)}
ariaLabel={SYMBOL_MENU_TAB_TITLES[tab]}
ariaLabel={lang(SYMBOL_MENU_TAB_TITLES[tab])}
round
faded
color="translucent"

View File

@ -2,7 +2,7 @@ import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useEffect } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiMessage, ApiWebPage } from '../../../api/types';
import type { ApiMessage, ApiMessageEntityTextUrl, ApiWebPage } from '../../../api/types';
import { ApiMessageEntityTypes } from '../../../api/types';
import type { ISettings } from '../../../types';
@ -54,7 +54,9 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
const link = useDebouncedMemo(() => {
const { text, entities } = parseMessageInput(messageText);
const linkEntity = entities && entities.find(({ type }) => type === ApiMessageEntityTypes.TextUrl);
const linkEntity = entities?.find((entity): entity is ApiMessageEntityTextUrl => (
entity.type === ApiMessageEntityTypes.TextUrl
));
if (linkEntity) {
return linkEntity.url;
}

View File

@ -20,14 +20,14 @@ export default function useStickerTooltip(
(IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1)
|| (!IS_EMOJI_SUPPORTED && Boolean(html.match(/^<img.[^>]*?>$/g)))
);
const hasStickers = Boolean(stickers) && isSingleEmoji;
const hasStickers = Boolean(stickers?.length) && isSingleEmoji;
useEffect(() => {
if (isDisabled) return;
if (isAllowed && isSingleEmoji) {
loadStickersForEmoji({
emoji: IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1],
emoji: IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1]!,
});
} else if (hasStickers || !isSingleEmoji) {
clearStickersForEmoji();

View File

@ -5,7 +5,9 @@ import React, {
import { getActions, getGlobal, withGlobal } from '../../../global';
import type { MessageListType } from '../../../global/types';
import type { ApiAvailableReaction, ApiMessage } from '../../../api/types';
import type {
ApiAvailableReaction, ApiStickerSetInfo, ApiMessage, ApiStickerSet,
} from '../../../api/types';
import type { IAlbum, IAnchorPosition } from '../../../types';
import {
@ -15,6 +17,8 @@ import {
selectCurrentMessageList, selectIsCurrentUserPremium,
selectIsMessageProtected,
selectIsPremiumPurchaseBlocked,
selectMessageCustomEmojiSets,
selectStickerSet,
} from '../../../global/selectors';
import {
isActionMessage, isChatChannel,
@ -52,6 +56,8 @@ export type OwnProps = {
type StateProps = {
availableReactions?: ApiAvailableReaction[];
customEmojiSetsInfo?: ApiStickerSetInfo[];
customEmojiSets?: ApiStickerSet[];
noOptions?: boolean;
canSendNow?: boolean;
canReschedule?: boolean;
@ -89,6 +95,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
messageListType,
chatUsername,
message,
customEmojiSetsInfo,
customEmojiSets,
album,
anchor,
onClose,
@ -143,6 +151,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
loadReactors,
copyMessagesByIds,
saveGif,
loadStickers,
cancelPollVote,
closePoll,
} = getActions();
@ -156,6 +165,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag();
// `undefined` indicates that emoji are present and loading
const hasCustomEmoji = customEmojiSetsInfo === undefined || Boolean(customEmojiSetsInfo.length);
useEffect(() => {
if (canShowSeenBy && isOpen) {
loadSeenBy({ chatId: message.chatId, messageId: message.id });
@ -168,6 +180,14 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
}
}, [canShowReactionsCount, isOpen, loadReactors, message.chatId, message.id]);
useEffect(() => {
if (customEmojiSetsInfo?.length && customEmojiSets?.length !== customEmojiSetsInfo.length) {
customEmojiSetsInfo.forEach((set) => {
loadStickers({ stickerSetInfo: set });
});
}
}, [customEmojiSetsInfo, customEmojiSets, loadStickers]);
useEffect(() => {
if (!hasFullInfo && !isPrivate && isOpen) {
loadFullChat({ chatId: message.chatId });
@ -403,6 +423,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canRevote={canRevote}
canClosePoll={canClosePoll}
canShowSeenBy={canShowSeenBy}
hasCustomEmoji={hasCustomEmoji}
customEmojiSets={customEmojiSets}
isDownloading={isDownloading}
seenByRecentUsers={seenByRecentUsers}
onReply={handleReply}
@ -516,6 +538,11 @@ export default memo(withGlobal<OwnProps>(
const canCopyNumber = Boolean(message.content.contact);
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const customEmojiSetsInfo = selectMessageCustomEmojiSets(global, message);
const customEmojiSetsNotFiltered = customEmojiSetsInfo?.map((set) => selectStickerSet(global, set));
const customEmojiSets = customEmojiSetsNotFiltered?.every<ApiStickerSet>(Boolean)
? customEmojiSetsNotFiltered : undefined;
return {
availableReactions: global.availableReactions,
noOptions,
@ -547,6 +574,8 @@ export default memo(withGlobal<OwnProps>(
canShowReactionList: !isLocal && !isAction && !isScheduled && chat?.id !== SERVICE_NOTIFICATIONS_USER_ID,
canRemoveReaction,
canBuyPremium: !isCurrentUserPremium && !selectIsPremiumPurchaseBlocked(global),
customEmojiSetsInfo,
customEmojiSets,
};
},
)(ContextMenuContainer));

View File

@ -507,7 +507,13 @@ const Message: FC<OwnProps & StateProps> = ({
const withAppendix = contentClassName.includes('has-appendix');
const textParts = renderMessageText(
message, highlight, isEmojiOnlyMessage(customShape), undefined, undefined, isProtected,
message,
highlight,
isEmojiOnlyMessage(customShape),
undefined,
undefined,
isProtected,
observeIntersectionForAnimatedStickers,
);
let metaPosition!: MetaPosition;

View File

@ -5,7 +5,7 @@ import React, {
import { getActions } from '../../../global';
import type {
ApiAvailableReaction, ApiMessage, ApiSponsoredMessage, ApiUser,
ApiAvailableReaction, ApiMessage, ApiSponsoredMessage, ApiStickerSet, ApiUser,
} from '../../../api/types';
import type { IAnchorPosition } from '../../../types';
@ -14,6 +14,7 @@ import { disableScrolling, enableScrolling } from '../../../util/scrollLock';
import { getUserFullName } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useContextMenuPosition from '../../../hooks/useContextMenuPosition';
@ -21,6 +22,8 @@ import useLang from '../../../hooks/useLang';
import Menu from '../../ui/Menu';
import MenuItem from '../../ui/MenuItem';
import MenuSeparator from '../../ui/MenuSeparator';
import Skeleton from '../../ui/Skeleton';
import Avatar from '../../common/Avatar';
import ReactionSelector from './ReactionSelector';
@ -59,6 +62,8 @@ type OwnProps = {
isDownloading?: boolean;
canShowSeenBy?: boolean;
seenByRecentUsers?: ApiUser[];
hasCustomEmoji?: boolean;
customEmojiSets?: ApiStickerSet[];
onReply?: () => void;
onEdit?: () => void;
onPin?: () => void;
@ -124,6 +129,8 @@ const MessageContextMenu: FC<OwnProps> = ({
canRemoveReaction,
canShowReactionList,
seenByRecentUsers,
hasCustomEmoji,
customEmojiSets,
onReply,
onEdit,
onPin,
@ -151,7 +158,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onAboutAds,
onSponsoredHide,
}) => {
const { showNotification } = getActions();
const { showNotification, openStickerSet, openCustomEmojiSets } = getActions();
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
@ -171,6 +178,22 @@ const MessageContextMenu: FC<OwnProps> = ({
onClose();
}, [lang, onClose, showNotification]);
const handleOpenCustomEmojiSets = useCallback(() => {
if (!customEmojiSets) return;
if (customEmojiSets.length === 1) {
openStickerSet({
stickerSetInfo: {
shortName: customEmojiSets[0].shortName,
},
});
} else {
openCustomEmojiSets({
setIds: customEmojiSets.map((set) => set.id),
});
}
onClose();
}, [customEmojiSets, onClose, openCustomEmojiSets, openStickerSet]);
const copyOptions = isSponsoredMessage
? []
: getMessageCopyOptions(
@ -332,6 +355,27 @@ const MessageContextMenu: FC<OwnProps> = ({
</MenuItem>
)}
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
{hasCustomEmoji && (
<>
<MenuSeparator />
{!customEmojiSets && (
<>
<Skeleton inline className="menu-loading-row" />
<Skeleton inline className="menu-loading-row" />
</>
)}
{customEmojiSets && customEmojiSets.length === 1 && (
<MenuItem withWrap onClick={handleOpenCustomEmojiSets} className="menu-custom-emoji-sets">
{renderText(lang('MessageContainsEmojiPack', customEmojiSets[0].title), ['simple_markdown', 'emoji'])}
</MenuItem>
)}
{customEmojiSets && customEmojiSets.length > 1 && (
<MenuItem withWrap onClick={handleOpenCustomEmojiSets} className="menu-custom-emoji-sets">
{renderText(lang('MessageContainsEmojiPacks', customEmojiSets.length), ['simple_markdown'])}
</MenuItem>
)}
</>
)}
{isSponsoredMessage && <MenuItem icon="help" onClick={onAboutAds}>{lang('SponsoredMessageInfo')}</MenuItem>}
{isSponsoredMessage && onSponsoredHide && (
<MenuItem icon="stop" onClick={onSponsoredHide}>{lang('HideAd')}</MenuItem>

View File

@ -4,23 +4,22 @@ import React, { useCallback, useEffect, useRef } from '../../../lib/teact/teact'
import type { ApiMessage } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { NO_STICKER_SET_ID } from '../../../config';
import { getStickerDimensions } from '../../common/helpers/mediaDimensions';
import { getMessageMediaFormat, getMessageMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import safePlay from '../../../util/safePlay';
import { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getActions } from '../../../global';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMedia from '../../../hooks/useMedia';
import useMediaTransition from '../../../hooks/useMediaTransition';
import useFlag from '../../../hooks/useFlag';
import useWebpThumbnail from '../../../hooks/useWebpThumbnail';
import safePlay from '../../../util/safePlay';
import { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getActions } from '../../../global';
import useThumbnail from '../../../hooks/useThumbnail';
import useLang from '../../../hooks/useLang';
import AnimatedSticker from '../../common/AnimatedSticker';
import StickerSetModal from '../../common/StickerSetModal.async';
import './Sticker.scss';
@ -43,20 +42,18 @@ const Sticker: FC<OwnProps> = ({
message, observeIntersection, observeIntersectionForPlaying, shouldLoop, lastSyncTime,
shouldPlayEffect, onPlayEffect, onStopEffect,
}) => {
const { showNotification } = getActions();
const { showNotification, openStickerSet } = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const [isModalOpen, openModal, closeModal] = useFlag();
const sticker = message.content.sticker!;
const {
isLottie, stickerSetId, isVideo, hasEffect,
isLottie, stickerSetInfo, isVideo, hasEffect,
} = sticker;
const canDisplayVideo = IS_WEBM_SUPPORTED;
const isMemojiSticker = stickerSetId === NO_STICKER_SET_ID;
const isMemojiSticker = 'isMissing' in stickerSetInfo;
const [isPlayingEffect, startPlayingEffect, stopPlayingEffect] = useFlag();
const shouldLoad = useIsIntersecting(ref, observeIntersection);
@ -68,7 +65,7 @@ const Sticker: FC<OwnProps> = ({
const previewMediaHash = isVideo && !canDisplayVideo && (
sticker.isPreloadedGlobally ? `sticker${sticker.id}?size=m` : getMessageMediaHash(message, 'pictogram'));
const previewBlobUrl = useMedia(previewMediaHash);
const thumbDataUri = useWebpThumbnail(message);
const thumbDataUri = useThumbnail(sticker);
const previewUrl = previewBlobUrl || thumbDataUri;
const mediaData = useMedia(
@ -122,6 +119,12 @@ const Sticker: FC<OwnProps> = ({
}
}, [hasEffect, shouldPlayEffect, onPlayEffect, shouldPlay, startPlayingEffect]);
const openModal = useCallback(() => {
openStickerSet({
stickerSetInfo: sticker.stickerSetInfo,
});
}, [openStickerSet, sticker]);
const handleClick = useCallback(() => {
if (hasEffect) {
if (isPlayingEffect) {
@ -195,11 +198,6 @@ const Sticker: FC<OwnProps> = ({
onEnded={handleEffectEnded}
/>
)}
<StickerSetModal
isOpen={isModalOpen}
fromSticker={sticker}
onClose={closeModal}
/>
</div>
);
};

View File

@ -406,10 +406,16 @@
}
}
&:not(.custom-shape) .text-content .emoji {
width: calc(1.25 * var(--message-text-size, 1rem));
height: calc(1.25 * var(--message-text-size, 1rem));
background-size: calc(1.25 * var(--message-text-size, 1rem));
&:not(.custom-shape) .text-content {
.emoji {
width: calc(1.25 * var(--message-text-size, 1rem));
height: calc(1.25 * var(--message-text-size, 1rem));
background-size: calc(1.25 * var(--message-text-size, 1rem));
}
.text-entity-custom-emoji {
--custom-emoji-size: calc(1.25 * var(--message-text-size, 1rem));
}
}
.no-media-corners {
@ -823,6 +829,31 @@
}
}
.text-entity-custom-emoji {
display: inline-block;
vertical-align: text-bottom;
--custom-emoji-size: 1.5rem;
width: var(--custom-emoji-size);
height: var(--custom-emoji-size);
& > video, & > img {
width: calc(100% + 1px) !important;
height: calc(100% + 1px) !important;
vertical-align: baseline;
}
& > .AnimatedSticker {
width: var(--custom-emoji-size) !important;
height: var(--custom-emoji-size) !important;
display: flex !important;
& > canvas {
width: var(--custom-emoji-size) !important;
height: var(--custom-emoji-size) !important;
}
}
}
.text-entity-code {
color: var(--color-code);
background: var(--color-code-bg);

View File

@ -317,14 +317,9 @@ const PaymentModal: FC<OwnProps & StateProps & GlobalStateProps> = ({
}
}, [step, lang]);
const buttonText = useMemo(() => {
switch (step) {
case PaymentStep.Checkout:
return lang('Checkout.PayPrice', formatCurrency(totalPrice, currency!, lang.code));
default:
return lang('Next');
}
}, [step, lang, currency, totalPrice]);
const buttonText = step === PaymentStep.Checkout
? lang('Checkout.PayPrice', formatCurrency(totalPrice, currency!, lang.code))
: lang('Next');
const isSubmitDisabled = isLoading
|| Boolean(step === PaymentStep.Checkout && invoiceContent?.isRecurring && !isTosAccepted);

View File

@ -1,6 +1,6 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useRef, useState,
memo, useEffect, useRef,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
@ -24,6 +24,7 @@ type StateProps = {
query?: string;
featuredIds?: string[];
resultIds?: string[];
isModalOpen: boolean;
};
const INTERSECTION_THROTTLE = 200;
@ -31,11 +32,12 @@ const INTERSECTION_THROTTLE = 200;
const runThrottled = throttle((cb) => cb(), 60000, true);
const StickerSearch: FC<OwnProps & StateProps> = ({
onClose,
isActive,
query,
featuredIds,
resultIds,
isModalOpen,
onClose,
}) => {
const { loadFeaturedStickers } = getActions();
@ -44,8 +46,6 @@ const StickerSearch: FC<OwnProps & StateProps> = ({
const lang = useLang();
const [isModalOpen, setIsModalOpen] = useState(false);
const {
observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE });
@ -74,8 +74,7 @@ const StickerSearch: FC<OwnProps & StateProps> = ({
key={id}
stickerSetId={id}
observeIntersection={observeIntersection}
isSomeModalOpen={isModalOpen}
onModalToggle={setIsModalOpen}
isModalOpen={isModalOpen}
/>
));
}
@ -90,8 +89,7 @@ const StickerSearch: FC<OwnProps & StateProps> = ({
key={id}
stickerSetId={id}
observeIntersection={observeIntersection}
isSomeModalOpen={isModalOpen}
onModalToggle={setIsModalOpen}
isModalOpen={isModalOpen}
/>
));
}
@ -116,6 +114,7 @@ export default memo(withGlobal(
query,
featuredIds: featured.setIds,
resultIds,
isModalOpen: Boolean(global.openedStickerSetShortName),
};
},
)(StickerSearch));

View File

@ -4,25 +4,21 @@ import React, {
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiStickerSet } from '../../api/types';
import type { ApiSticker, ApiStickerSet } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { STICKER_SIZE_SEARCH } from '../../config';
import { selectIsCurrentUserPremium, selectShouldLoopStickers, selectStickerSet } from '../../global/selectors';
import useFlag from '../../hooks/useFlag';
import useOnChange from '../../hooks/useOnChange';
import useLang from '../../hooks/useLang';
import Button from '../ui/Button';
import StickerButton from '../common/StickerButton';
import StickerSetModal from '../common/StickerSetModal.async';
import Spinner from '../ui/Spinner';
type OwnProps = {
stickerSetId: string;
observeIntersection: ObserveFn;
isSomeModalOpen: boolean;
onModalToggle: (isOpen: boolean) => void;
isModalOpen?: boolean;
};
type StateProps = {
@ -36,20 +32,14 @@ const STICKERS_TO_DISPLAY = 5;
const StickerSetResult: FC<OwnProps & StateProps> = ({
stickerSetId, observeIntersection, set, shouldPlay,
isSomeModalOpen, onModalToggle, isCurrentUserPremium,
isModalOpen, isCurrentUserPremium,
}) => {
const { loadStickers, toggleStickerSet } = getActions();
const { loadStickers, toggleStickerSet, openStickerSet } = getActions();
const lang = useLang();
const isAdded = set && Boolean(set.installedDate);
const areStickersLoaded = Boolean(set?.stickers);
const [isModalOpen, openModal, closeModal] = useFlag();
useOnChange(() => {
onModalToggle(isModalOpen);
}, [isModalOpen, onModalToggle]);
const displayedStickers = useMemo(() => {
if (!set) {
return [];
@ -65,15 +55,23 @@ const StickerSetResult: FC<OwnProps & StateProps> = ({
useEffect(() => {
// Featured stickers are initialized with one sticker in collection (cover of SickerSet)
if (!areStickersLoaded && displayedStickers.length < STICKERS_TO_DISPLAY) {
loadStickers({ stickerSetId });
if (!areStickersLoaded && displayedStickers.length < STICKERS_TO_DISPLAY && set) {
loadStickers({
stickerSetInfo: {
shortName: set.shortName,
},
});
}
}, [areStickersLoaded, displayedStickers.length, loadStickers, stickerSetId]);
}, [areStickersLoaded, displayedStickers.length, loadStickers, set, stickerSetId]);
const handleAddClick = useCallback(() => {
toggleStickerSet({ stickerSetId });
}, [toggleStickerSet, stickerSetId]);
const handleStickerClick = useCallback((sticker: ApiSticker) => {
openStickerSet({ stickerSetInfo: sticker.stickerSetInfo });
}, [openStickerSet]);
if (!set) {
return undefined;
}
@ -105,21 +103,14 @@ const StickerSetResult: FC<OwnProps & StateProps> = ({
sticker={sticker}
size={STICKER_SIZE_SEARCH}
observeIntersection={observeIntersection}
noAnimate={!shouldPlay || isModalOpen || isSomeModalOpen}
clickArg={undefined}
onClick={openModal}
noAnimate={!shouldPlay || isModalOpen}
clickArg={sticker}
onClick={handleStickerClick}
noContextMenu
isCurrentUserPremium={isCurrentUserPremium}
/>
))}
</div>
{canRenderStickers && (
<StickerSetModal
isOpen={isModalOpen}
fromSticker={displayedStickers[0]}
onClose={closeModal}
/>
)}
</div>
);
};

View File

@ -45,6 +45,8 @@
transition: background-color 0.15s, color 0.15s;
text-decoration: none !important;
--premium-gradient: linear-gradient(88.39deg, #6C93FF -2.56%, #976FFF 51.27%, #DF69D1 107.39%);
// @optimization
&:active,
&.clicked,
@ -339,8 +341,10 @@
}
&.shiny::before {
position: absolute;
content: "";
position: absolute;
top: 0;
display: block;
width: 100%;
height: 100%;
@ -359,4 +363,8 @@
}
}
}
&.premium {
background: var(--premium-gradient);
}
}

View File

@ -39,6 +39,7 @@ export type OwnProps = {
tabIndex?: number;
isRtl?: boolean;
isShiny?: boolean;
withPremiumGradient?: boolean;
noPreventDefault?: boolean;
shouldStopPropagation?: boolean;
style?: string;
@ -74,6 +75,7 @@ const Button: FC<OwnProps> = ({
isText,
isLoading,
isShiny,
withPremiumGradient,
ariaLabel,
ariaControls,
hasPopup,
@ -114,6 +116,7 @@ const Button: FC<OwnProps> = ({
isClicked && 'clicked',
backgroundImage && 'with-image',
isShiny && 'shiny',
withPremiumGradient && 'premium',
);
const handleClick = useCallback((e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {

View File

@ -94,4 +94,9 @@
background: var(--color-background);
}
}
.menu-loading-row {
margin: 0.125rem 1rem;
width: calc(100% - 2rem);
}
}

View File

@ -133,4 +133,21 @@
}
}
}
b {
font-weight: 600;
}
&.wrap {
display: block;
white-space: normal;
}
&.menu-custom-emoji-sets {
margin: 0 0.25rem;
padding: 0.5rem 0.75rem;
font-weight: 400;
font-size: small;
line-height: 1.125rem;
}
}

View File

@ -22,6 +22,7 @@ type OwnProps = {
disabled?: boolean;
destructive?: boolean;
ariaLabel?: string;
withWrap?: boolean;
};
const MenuItem: FC<OwnProps> = (props) => {
@ -36,6 +37,7 @@ const MenuItem: FC<OwnProps> = (props) => {
disabled,
destructive,
ariaLabel,
withWrap,
onContextMenu,
} = props;
@ -72,6 +74,7 @@ const MenuItem: FC<OwnProps> = (props) => {
disabled && 'disabled',
destructive && 'destructive',
IS_COMPACT_MENU && 'compact',
withWrap && 'wrap',
);
const content = (

View File

@ -5,6 +5,12 @@
height: 100%;
overflow: hidden;
&.inline {
display: inline-block;
height: 1rem;
border-radius: 0.5rem;
}
&.round {
border-radius: 50%;
}
@ -37,6 +43,7 @@
&.wave::before {
content: "";
display: block;
position: absolute;
width: 100%;
height: 100%;
background: linear-gradient(to right, transparent 0%, var(--color-skeleton-foreground) 50%, transparent 100%);

View File

@ -12,6 +12,7 @@ type OwnProps = {
width?: number;
height?: number;
forceAspectRatio?: boolean;
inline?: boolean;
className?: string;
};
@ -21,14 +22,15 @@ const Skeleton: FC<OwnProps> = ({
width,
height,
forceAspectRatio,
inline,
className,
}) => {
const classNames = buildClassName('Skeleton', variant, animation, className);
const classNames = buildClassName('Skeleton', variant, animation, className, inline && 'inline');
const aspectRatio = (width && height) ? `aspect-ratio: ${width}/${height}` : undefined;
const style = forceAspectRatio ? aspectRatio
: buildStyle(Boolean(width) && `width: ${width}px`, Boolean(height) && `height: ${height}px`);
return (
<div className={classNames} style={style} />
<div className={classNames} style={style}>{inline && '\u00A0'}</div>
);
};

View File

@ -32,6 +32,7 @@ export const GLOBAL_STATE_CACHE_KEY = 'tt-global-state';
export const GLOBAL_STATE_CACHE_USER_LIST_LIMIT = 500;
export const GLOBAL_STATE_CACHE_CHAT_LIST_LIMIT = 200;
export const GLOBAL_STATE_CACHE_CHATS_WITH_MESSAGES_LIMIT = 30;
export const GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT = 150;
export const MEDIA_CACHE_DISABLED = false;
export const MEDIA_CACHE_NAME = 'tt-media';
@ -134,10 +135,12 @@ export const STICKER_SIZE_INLINE_MOBILE_FACTOR = 11;
export const STICKER_SIZE_AUTH = 160;
export const STICKER_SIZE_AUTH_MOBILE = 120;
export const STICKER_SIZE_PICKER = 64;
export const EMOJI_SIZE_PICKER = 40;
export const STICKER_SIZE_GENERAL_SETTINGS = 48;
export const STICKER_SIZE_PICKER_HEADER = 32;
export const STICKER_SIZE_SEARCH = 64;
export const STICKER_SIZE_MODAL = 64;
export const EMOJI_SIZE_MODAL = 40;
export const STICKER_SIZE_TWO_FA = 160;
export const STICKER_SIZE_PASSCODE = 160;
export const STICKER_SIZE_DISCUSSION_GROUPS = 140;
@ -146,7 +149,6 @@ export const STICKER_SIZE_INLINE_BOT_RESULT = 100;
export const STICKER_SIZE_JOIN_REQUESTS = 140;
export const STICKER_SIZE_INVITES = 140;
export const RECENT_STICKERS_LIMIT = 20;
export const NO_STICKER_SET_ID = 'NO_STICKER_SET';
export const RECENT_SYMBOL_SET_ID = 'recent';
export const FAVORITE_SYMBOL_SET_ID = 'favorite';
export const CHAT_STICKER_SET_ID = 'chatStickers';

View File

@ -615,8 +615,10 @@ addActionHandler('openTelegramLink', (global, actions, payload) => {
}
if (part1 === 'addstickers' || part1 === 'addemoji') {
actions.openStickerSetShortName({
stickerSetShortName: part2,
actions.openStickerSet({
stickerSetInfo: {
shortName: part2,
},
});
return;
}
@ -1237,9 +1239,10 @@ export async function loadFullChat(chat: ApiChat) {
const stickerSet = fullInfo.stickerSet;
if (stickerSet) {
getActions().loadStickers({
stickerSetId: stickerSet.id,
stickerSetAccessHash: stickerSet.accessHash,
stickerSetShortName: stickerSet.shortName,
stickerSetInfo: {
id: stickerSet.id,
accessHash: stickerSet.accessHash,
},
});
}

View File

@ -69,6 +69,7 @@ import {
selectUser,
selectSendAs,
selectSponsoredMessage,
selectIsCurrentUserPremium,
selectForwardsContainVoiceMessages,
} from '../../selectors';
import {
@ -607,6 +608,7 @@ addActionHandler('forwardMessages', (global, action, payload) => {
const {
fromChatId, messageIds, toChatId, withMyScore, noAuthors, noCaptions,
} = global.forwardMessages;
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const fromChat = fromChatId ? selectChat(global, fromChatId) : undefined;
const toChat = toChatId ? selectChat(global, toChatId) : undefined;
const messages = fromChatId && messageIds
@ -635,6 +637,7 @@ addActionHandler('forwardMessages', (global, action, payload) => {
withMyScore,
noAuthors,
noCaptions,
isCurrentUserPremium,
});
}
@ -740,6 +743,28 @@ addActionHandler('transcribeAudio', async (global, actions, payload) => {
setGlobal(global);
});
addActionHandler('loadCustomEmojis', async (global, actions, payload) => {
const { ids, ignoreCache } = payload;
const newCustomEmojiIds = ignoreCache ? ids
: unique(ids.filter((documentId) => !global.customEmojis.byId[documentId]));
const customEmoji = await callApi('fetchCustomEmoji', {
documentId: newCustomEmojiIds,
});
if (!customEmoji) return;
global = getGlobal();
setGlobal({
...global,
customEmojis: {
...global.customEmojis,
byId: {
...global.customEmojis.byId,
...buildCollectionByKey(customEmoji, 'id'),
},
},
});
});
async function loadWebPagePreview(message: string) {
const webPagePreview = await callApi('fetchWebPagePreview', { message });

View File

@ -2,7 +2,7 @@ import {
addActionHandler, getActions, getGlobal, setGlobal,
} from '../../index';
import type { ApiSticker } from '../../../api/types';
import type { ApiStickerSetInfo, ApiSticker } from '../../../api/types';
import type { LangCode } from '../../../types';
import { callApi } from '../../../api/gramjs';
import { onTickEnd, pause, throttle } from '../../../util/schedulers';
@ -25,24 +25,39 @@ const ADDED_SETS_THROTTLE_CHUNK = 10;
const searchThrottled = throttle((cb) => cb(), 500, false);
addActionHandler('loadStickerSets', (global) => {
const { hash } = global.stickers.added || {};
void loadStickerSets(hash);
addActionHandler('loadStickerSets', (global, actions) => {
void loadStickerSets(global.stickers.added.hash);
void loadCustomEmojiSets(global.customEmojis.added.hash);
actions.loadCustomEmojis({
ids: global.recentCustomEmojis,
});
});
addActionHandler('loadAddedStickers', async (global, actions) => {
const { setIds: addedSetIds } = global.stickers.added;
const cached = global.stickers.setsById;
if (!addedSetIds || !addedSetIds.length) {
const {
added: {
setIds: addedSetIds = [],
},
setsById: cached,
} = global.stickers;
const {
added: {
setIds: customEmojiSetIds = [],
},
} = global.customEmojis;
const setIdsToLoad = [...addedSetIds, ...customEmojiSetIds];
if (!setIdsToLoad.length) {
return;
}
for (let i = 0; i < addedSetIds.length; i++) {
const id = addedSetIds[i];
for (let i = 0; i < setIdsToLoad.length; i++) {
const id = setIdsToLoad[i];
if (cached[id]?.stickers) {
continue; // Already loaded
}
actions.loadStickers({ stickerSetId: id });
actions.loadStickers({
stickerSetInfo: { id, accessHash: cached[id].accessHash },
});
if (i % ADDED_SETS_THROTTLE_CHUNK === 0 && i > 0) {
await pause(ADDED_SETS_THROTTLE);
@ -82,6 +97,28 @@ addActionHandler('loadPremiumStickers', async (global) => {
});
});
addActionHandler('loadPremiumSetStickers', async (global) => {
const { hash } = global.stickers.premium || {};
const result = await callApi('fetchStickersForEmoji', { emoji: '📂⭐️', hash });
if (!result) {
return;
}
global = getGlobal();
setGlobal({
...global,
stickers: {
...global.stickers,
premiumSet: {
hash: result.hash,
stickers: result.stickers,
},
},
});
});
addActionHandler('loadGreetingStickers', async (global) => {
const { hash } = global.stickers.greeting || {};
@ -124,25 +161,10 @@ addActionHandler('loadPremiumGifts', async () => {
});
addActionHandler('loadStickers', (global, actions, payload) => {
const { stickerSetId, stickerSetShortName } = payload!;
let { stickerSetAccessHash } = payload!;
if (!stickerSetAccessHash && !stickerSetShortName) {
const stickerSet = selectStickerSet(global, stickerSetId);
if (!stickerSet) {
if (global.openedStickerSetShortName === stickerSetShortName) {
setGlobal({
...global,
openedStickerSetShortName: undefined,
});
}
return;
}
stickerSetAccessHash = stickerSet.accessHash;
}
void loadStickers(stickerSetId, stickerSetAccessHash!, stickerSetShortName);
const { stickerSetInfo } = payload;
const cachedSet = selectStickerSet(global, stickerSetInfo);
if (cachedSet && cachedSet.count === cachedSet?.stickers?.length) return; // Already fully loaded
void loadStickers(stickerSetInfo);
});
addActionHandler('loadAnimatedEmojis', () => {
@ -323,6 +345,20 @@ addActionHandler('loadEmojiKeywords', async (global, actions, payload: { languag
});
});
async function loadCustomEmojiSets(hash?: string) {
const addedCustomEmojis = await callApi('fetchCustomEmojiSets', { hash });
if (!addedCustomEmojis) {
return;
}
setGlobal(updateStickerSets(
getGlobal(),
'added',
addedCustomEmojis.hash,
addedCustomEmojis.sets,
));
}
async function loadStickerSets(hash?: string) {
const addedStickers = await callApi('fetchStickerSets', { hash });
if (!addedStickers) {
@ -385,10 +421,10 @@ async function loadFeaturedStickers(hash?: string) {
));
}
async function loadStickers(stickerSetId: string, accessHash: string, stickerSetShortName?: string) {
async function loadStickers(stickerSetInfo: ApiStickerSetInfo) {
const stickerSet = await callApi(
'fetchStickers',
{ stickerSetShortName, stickerSetId, accessHash },
{ stickerSetInfo },
);
let global = getGlobal();
@ -398,7 +434,7 @@ async function loadStickers(stickerSetId: string, accessHash: string, stickerSet
message: getTranslation('StickerPack.ErrorNotFound'),
});
});
if (global.openedStickerSetShortName === stickerSetShortName) {
if ('shortName' in stickerSetInfo && global.openedStickerSetShortName === stickerSetInfo.shortName) {
setGlobal({
...global,
openedStickerSetShortName: undefined,
@ -494,7 +530,7 @@ addActionHandler('searchMoreGifs', (global) => {
});
addActionHandler('loadStickersForEmoji', (global, actions, payload) => {
const { emoji } = payload!;
const { emoji } = payload;
const { hash } = global.stickers.forEmoji;
void searchThrottled(() => {
@ -512,31 +548,18 @@ addActionHandler('clearStickersForEmoji', (global) => {
};
});
addActionHandler('openStickerSetShortName', (global, actions, payload) => {
const { stickerSetShortName } = payload;
return {
...global,
openedStickerSetShortName: stickerSetShortName,
};
});
addActionHandler('openStickerSet', async (global, actions, payload) => {
const { sticker } = payload;
if (!selectStickerSet(global, sticker.stickerSetId)) {
if (!sticker.stickerSetAccessHash) {
actions.showNotification({
message: getTranslation('StickerPack.ErrorNotFound'),
});
return;
}
await loadStickers(sticker.stickerSetId, sticker.stickerSetAccessHash);
const { stickerSetInfo } = payload;
if (!selectStickerSet(global, stickerSetInfo)) {
await loadStickers(stickerSetInfo);
}
global = getGlobal();
const set = selectStickerSet(global, sticker.stickerSetId);
const set = selectStickerSet(global, stickerSetInfo);
if (!set?.shortName) {
actions.showNotification({
message: getTranslation('StickerPack.ErrorNotFound'),
});
return;
}

View File

@ -30,7 +30,7 @@ import {
} from '../../selectors';
import { init as initFolderManager } from '../../../util/folderManager';
const RELEASE_STATUS_TIMEOUT = 15000; // 10 sec;
const RELEASE_STATUS_TIMEOUT = 15000; // 15 sec;
let releaseStatusTimeout: number | undefined;

View File

@ -37,7 +37,7 @@ addActionHandler('apiUpdate', (global, actions, update) => {
break;
case 'updateStickerSetsOrder':
actions.reorderStickerSets({ order: update.order });
actions.reorderStickerSets({ order: update.order, isCustomEmoji: update.isCustomEmoji });
break;
case 'updateSavedGifs':

View File

@ -2,14 +2,16 @@ import { addActionHandler, setGlobal } from '../../index';
import type { ApiError } from '../../../api/types';
import { GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT } from '../../../config';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../../util/environment';
import getReadableErrorText from '../../../util/getReadableErrorText';
import {
selectChatMessage, selectCurrentMessageList, selectIsTrustedBot,
} from '../../selectors';
import generateIdFor from '../../../util/generateIdFor';
import { unique } from '../../../util/iteratees';
const MAX_STORED_EMOJIS = 18; // Represents two rows of recent emojis
const MAX_STORED_EMOJIS = 8 * 4; // Represents four rows of recent emojis
addActionHandler('toggleChatInfo', (global, action, payload) => {
return {
@ -139,7 +141,7 @@ addActionHandler('toggleLeftColumn', (global) => {
});
addActionHandler('addRecentEmoji', (global, action, payload) => {
const { emoji } = payload!;
const { emoji } = payload;
const { recentEmojis } = global;
if (!recentEmojis) {
return {
@ -192,12 +194,12 @@ addActionHandler('addRecentSticker', (global, action, payload) => {
});
addActionHandler('reorderStickerSets', (global, action, payload) => {
const { order } = payload;
const { order, isCustomEmoji } = payload;
return {
...global,
stickers: {
...global.stickers,
added: {
[isCustomEmoji ? 'customEmoji' : 'added']: {
setIds: order,
},
},
@ -368,3 +370,38 @@ addActionHandler('closeLimitReachedModal', (global) => {
limitReachedModal: undefined,
};
});
addActionHandler('closeStickerSetModal', (global) => {
return {
...global,
openedStickerSetShortName: undefined,
};
});
addActionHandler('openCustomEmojiSets', (global, actions, payload) => {
const { setIds } = payload;
return {
...global,
openedCustomEmojiSetIds: setIds,
};
});
addActionHandler('closeCustomEmojiSets', (global) => {
return {
...global,
openedCustomEmojiSetIds: undefined,
};
});
addActionHandler('updateLastRenderedCustomEmojis', (global, actions, payload) => {
const { ids } = payload;
const { lastRendered } = global.customEmojis;
return {
...global,
customEmojis: {
...global.customEmojis,
lastRendered: unique([...lastRendered, ...ids]).slice(0, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT),
},
};
});

View File

@ -19,6 +19,7 @@ import {
ALL_FOLDER_ID,
ARCHIVED_FOLDER_ID,
DEFAULT_LIMITS,
GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT,
} from '../config';
import { IS_SINGLE_COLUMN_LAYOUT } from '../util/environment';
import { isHeavyAnimating } from '../hooks/useHeavyAnimationCheck';
@ -272,6 +273,20 @@ export function migrateCache(cached: GlobalState, initialState: GlobalState) {
if (cached.appConfig && !cached.appConfig.limits) {
cached.appConfig.limits = DEFAULT_LIMITS;
}
if (!cached.customEmojis) {
cached.customEmojis = {
added: {},
byId: {},
lastRendered: [],
};
}
if (!cached.stickers.premiumSet) {
cached.stickers.premiumSet = {
stickers: [],
};
}
}
function updateCache() {
@ -316,6 +331,7 @@ export function serializeGlobal(global: GlobalState) {
'topPeers',
'topInlineBots',
'recentEmojis',
'recentCustomEmojis',
'push',
'shouldShowContextMenuHint',
'leftColumnWidth',
@ -326,6 +342,7 @@ export function serializeGlobal(global: GlobalState) {
playbackRate: global.audioPlayer.playbackRate,
isMuted: global.audioPlayer.isMuted,
},
customEmojis: reduceCustomEmojis(global),
mediaViewer: {
volume: global.mediaViewer.volume,
playbackRate: global.mediaViewer.playbackRate,
@ -354,6 +371,18 @@ export function serializeGlobal(global: GlobalState) {
return JSON.stringify(reducedGlobal);
}
function reduceCustomEmojis(global: GlobalState): GlobalState['customEmojis'] {
const { lastRendered, byId } = global.customEmojis;
const idsToSave = lastRendered.slice(0, GLOBAL_STATE_CACHE_CUSTOM_EMOJI_LIMIT);
const byIdToSave = pick(byId, idsToSave);
return {
byId: byIdToSave,
lastRendered: idsToSave,
added: {},
};
}
function reduceShowChatInfo(global: GlobalState): boolean {
return window.innerWidth > MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN
? global.isChatInfoShown

View File

@ -1,5 +1,5 @@
import type {
ApiChat, ApiMessage, ApiReactions, ApiUser,
ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiReactions, ApiUser,
} from '../../api/types';
import { ApiMessageEntityTypes } from '../../api/types';
import type { LangFn } from '../../hooks/useLang';
@ -78,7 +78,7 @@ export function getMessageCustomShape(message: ApiMessage): boolean | number {
return true;
}
if (!text || photo || video || audio || voice || document || poll || webPage || contact) {
if (!text || text.entities?.length || photo || video || audio || voice || document || poll || webPage || contact) {
return false;
}
@ -88,7 +88,7 @@ export function getMessageCustomShape(message: ApiMessage): boolean | number {
export function getMessageSingleEmoji(message: ApiMessage) {
const { text } = message.content;
if (!(text && text.text.length <= 6)) {
if (!(text && text.text.length <= 6) || text.entities?.length) {
return undefined;
}
@ -104,15 +104,17 @@ export function getFirstLinkInMessage(message: ApiMessage) {
let match: RegExpMatchArray | null | undefined;
if (text?.entities) {
let link = text.entities.find((entity) => entity.type === ApiMessageEntityTypes.TextUrl);
if (link) {
match = link.url!.match(RE_LINK);
const firstTextUrl = text.entities.find((entity): entity is ApiMessageEntityTextUrl => (
entity.type === ApiMessageEntityTypes.TextUrl
));
if (firstTextUrl) {
match = firstTextUrl.url.match(RE_LINK);
}
if (!match) {
link = text.entities.find((entity) => entity.type === ApiMessageEntityTypes.Url);
if (link) {
const { offset, length } = link;
const firstUrl = text.entities.find((entity) => entity.type === ApiMessageEntityTypes.Url);
if (firstUrl) {
const { offset, length } = firstUrl;
match = text.text.substring(offset, offset + length).match(RE_LINK);
}
}

View File

@ -63,7 +63,8 @@ export const INITIAL_STATE: GlobalState = {
byMessageLocalId: {},
},
recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy'],
recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'],
recentCustomEmojis: ['5377305978079288312'],
stickers: {
setsById: {},
@ -80,6 +81,9 @@ export const INITIAL_STATE: GlobalState = {
premium: {
stickers: [],
},
premiumSet: {
stickers: [],
},
featured: {
setIds: [],
},
@ -87,6 +91,12 @@ export const INITIAL_STATE: GlobalState = {
forEmoji: {},
},
customEmojis: {
lastRendered: [],
byId: {},
added: {},
},
emojiKeywords: {},
gifs: {

View File

@ -22,6 +22,13 @@ export function updateStickerSets(
};
});
const regularSetIds = sets.filter((set) => !set.isEmoji).map((set) => set.id);
const addedEmojiSetIds = category === 'added' ? sets.filter((set) => set.isEmoji).map((set) => set.id) : [];
const customEmojis = sets.filter((set) => set.isEmoji)
.map((set) => set.stickers)
.flat()
.filter(Boolean);
return {
...global,
stickers: {
@ -36,10 +43,30 @@ export function updateStickerSets(
...(
category === 'search'
? { resultIds }
: { setIds: sets.map(({ id }) => id) }
: {
setIds: [
...(global.stickers[category].setIds || []),
...regularSetIds,
],
}
),
},
},
customEmojis: {
...global.customEmojis,
added: {
...global.customEmojis.added,
hash,
setIds: [
...(global.customEmojis.added.setIds || []),
...addedEmojiSetIds,
],
},
byId: {
...global.customEmojis.byId,
...buildCollectionByKey(customEmojis, 'id'),
},
},
};
}
@ -47,7 +74,8 @@ export function updateStickerSet(
global: GlobalState, stickerSetId: string, update: Partial<ApiStickerSet>,
): GlobalState {
const currentStickerSet = global.stickers.setsById[stickerSetId] || {};
const addedSets = global.stickers.added.setIds || [];
const isCustomEmoji = update.isEmoji || currentStickerSet.isEmoji;
const addedSets = (isCustomEmoji ? global.customEmojis.added.setIds : global.stickers.added.setIds) || [];
let setIds: string[] = addedSets;
if (update.installedDate && addedSets && !addedSets.includes(stickerSetId)) {
setIds = [stickerSetId, ...setIds];
@ -57,13 +85,16 @@ export function updateStickerSet(
setIds = setIds.filter((id) => id !== stickerSetId);
}
const customEmojiById = isCustomEmoji && currentStickerSet.stickers
&& buildCollectionByKey(currentStickerSet.stickers, 'id');
return {
...global,
stickers: {
...global.stickers,
added: {
...global.stickers.added,
setIds,
...(!isCustomEmoji && { setIds }),
},
setsById: {
...global.stickers.setsById,
@ -73,6 +104,17 @@ export function updateStickerSet(
},
},
},
customEmojis: {
...global.customEmojis,
byId: {
...global.customEmojis.byId,
...customEmojiById,
},
added: {
...global.customEmojis.added,
...(isCustomEmoji && { setIds }),
},
},
};
}
@ -135,10 +177,14 @@ export function updateStickersForEmoji(
}
export function rebuildStickersForEmoji(global: GlobalState): GlobalState {
const { emoji, stickers, hash } = global.stickers.forEmoji || {};
if (!emoji) {
return global;
if (global.stickers.forEmoji) {
const { emoji, stickers, hash } = global.stickers.forEmoji;
if (!emoji) {
return global;
}
return updateStickersForEmoji(global, emoji, stickers, hash);
}
return updateStickersForEmoji(global, emoji, stickers, hash);
return global;
}

View File

@ -1,12 +1,15 @@
import type { GlobalState, MessageListType, Thread } from '../types';
import type {
ApiChat,
ApiStickerSetInfo,
ApiMessage,
ApiMessageEntityCustomEmoji,
ApiMessageOutgoingStatus,
ApiUser,
} from '../../api/types';
import {
MAIN_THREAD_ID,
ApiMessageEntityTypes,
} from '../../api/types';
import { LOCAL_MESSAGE_MIN_ID, REPLIES_USER_ID, SERVICE_NOTIFICATIONS_USER_ID } from '../../config';
@ -976,6 +979,40 @@ export function selectCanScheduleUntilOnline(global: GlobalState, id: string) {
);
}
export function selectCustomEmojis(message: ApiMessage) {
const entities = message.content.text?.entities;
return entities?.filter((entity): entity is ApiMessageEntityCustomEmoji => (
entity.type === ApiMessageEntityTypes.CustomEmoji
));
}
export function selectMessageCustomEmojiSets(
global: GlobalState, message: ApiMessage,
): ApiStickerSetInfo[] | undefined {
const customEmojis = selectCustomEmojis(message);
if (!customEmojis) return MEMO_EMPTY_ARRAY;
const documents = customEmojis.map((entity) => global.customEmojis.byId[entity.documentId]);
// If some emoji still loading, do not return empty array
if (!documents.every(Boolean)) return undefined;
const sets = documents.map((doc) => doc.stickerSetInfo);
const setsWithoutDuplicates = sets.reduce((acc, set) => {
if ('shortName' in set) {
if (acc.some((s) => 'shortName' in s && s.shortName === set.shortName)) {
return acc;
}
}
if ('id' in set) {
if (acc.some((s) => 'id' in s && s.id === set.id)) {
return acc;
}
}
acc.push(set); // Optimization
return acc;
}, [] as ApiStickerSetInfo[]);
return setsWithoutDuplicates;
}
export function selectForwardsContainVoiceMessages(global: GlobalState) {
const { messageIds, fromChatId } = global.forwardMessages;
if (!messageIds) return false;

View File

@ -1,5 +1,7 @@
import type { GlobalState } from '../types';
import type { ApiSticker } from '../../api/types';
import type { ApiStickerSetInfo, ApiSticker, ApiStickerSet } from '../../api/types';
import { selectIsCurrentUserPremium } from './users';
export function selectIsStickerFavorite(global: GlobalState, sticker: ApiSticker) {
const { stickers } = global.stickers.favorite;
@ -14,16 +16,24 @@ export function selectCurrentGifSearch(global: GlobalState) {
return global.gifs.search;
}
export function selectStickerSet(global: GlobalState, id: string) {
return global.stickers.setsById[id];
}
export function selectStickerSet(global: GlobalState, id: string | ApiStickerSetInfo) {
if (typeof id === 'string') {
return global.stickers.setsById[id];
}
export function selectStickerSetByShortName(global: GlobalState, shortName: string) {
return Object.values(global.stickers.setsById).find((l) => l.shortName.toLowerCase() === shortName.toLowerCase());
if ('id' in id) {
return global.stickers.setsById[id.id];
}
if ('isMissing' in id) return undefined;
return Object.values(global.stickers.setsById).find(({ shortName }) => (
shortName.toLowerCase() === id.shortName.toLowerCase()
));
}
export function selectStickersForEmoji(global: GlobalState, emoji: string) {
const stickerSets = Object.values(global.stickers.setsById);
const addedSets = global.stickers.added.setIds;
let stickersForEmoji: ApiSticker[] = [];
// Favorites
global.stickers.favorite.stickers.forEach((sticker) => {
@ -31,7 +41,8 @@ export function selectStickersForEmoji(global: GlobalState, emoji: string) {
});
// Added sets
stickerSets.forEach(({ packs }) => {
addedSets?.forEach((id) => {
const packs = global.stickers.setsById[id].packs;
if (!packs) {
return;
}
@ -41,6 +52,27 @@ export function selectStickersForEmoji(global: GlobalState, emoji: string) {
return stickersForEmoji;
}
export function selectCustomEmojiForEmoji(global: GlobalState, emoji: string) {
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
const addedCustomSets = global.customEmojis.added.setIds;
let customEmojiForEmoji: ApiSticker[] = [];
// Added sets
addedCustomSets?.forEach((id) => {
const packs = global.stickers.setsById[id].packs;
if (!packs) {
return;
}
customEmojiForEmoji = customEmojiForEmoji.concat(packs[emoji] || [], packs[cleanEmoji(emoji)] || []);
});
return isCurrentUserPremium ? customEmojiForEmoji : customEmojiForEmoji.filter(({ isFree }) => isFree);
}
export function selectIsSetPremium(stickerSet: ApiStickerSet) {
return stickerSet.isEmoji && stickerSet.stickers?.some((sticker) => !sticker.isFree);
}
function cleanEmoji(emoji: string) {
// Some emojis (❤️ for example) with a service symbol 'VARIATION SELECTOR-16' are not recognized as animated
return emoji.replace('\ufe0f', '');

View File

@ -42,6 +42,7 @@ import type {
ApiTranscription,
ApiInputInvoice,
ApiInvoice,
ApiStickerSetInfo,
} from '../api/types';
import type {
FocusDirection,
@ -274,6 +275,7 @@ export type GlobalState = {
};
recentEmojis: string[];
recentCustomEmojis: string[];
stickers: {
setsById: Record<string, ApiStickerSet>;
@ -297,6 +299,10 @@ export type GlobalState = {
hash?: string;
stickers: ApiSticker[];
};
premiumSet: {
hash?: string;
stickers: ApiSticker[];
};
featured: {
hash?: string;
setIds?: string[];
@ -312,6 +318,15 @@ export type GlobalState = {
};
};
customEmojis: {
added: {
hash?: string;
setIds?: string[];
};
lastRendered: string[];
byId: Record<string, ApiSticker>;
};
animatedEmojis?: ApiStickerSet;
animatedEmojiEffects?: ApiStickerSet;
premiumGifts?: ApiStickerSet;
@ -542,6 +557,7 @@ export type GlobalState = {
safeLinkModalUrl?: string;
historyCalendarSelectedAt?: number;
openedStickerSetShortName?: string;
openedCustomEmojiSetIds?: string[];
activeDownloads: {
byChatId: Record<string, number[]>;
@ -857,7 +873,16 @@ export interface ActionPayloads {
exitForwardMode: never;
changeForwardRecipient: never;
// GIFs
loadSavedGifs: never;
// Stickers
loadStickers: {
stickerSetInfo: ApiStickerSetInfo;
};
loadAnimatedEmojis: never;
loadGreetingStickers: never;
addRecentSticker: {
sticker: ApiSticker;
};
@ -866,15 +891,16 @@ export interface ActionPayloads {
sticker: ApiSticker;
};
clearRecentStickers: {};
clearRecentStickers: never;
loadStickerSets: {};
loadAddedStickers: {};
loadRecentStickers: {};
loadFavoriteStickers: {};
loadFeaturedStickers: {};
loadStickerSets: never;
loadAddedStickers: never;
loadRecentStickers: never;
loadFavoriteStickers: never;
loadFeaturedStickers: never;
reorderStickerSets: {
isCustomEmoji?: boolean;
order: string[];
};
@ -882,13 +908,31 @@ export interface ActionPayloads {
stickerSet: ApiStickerSet;
};
openStickerSetShortName: {
stickerSetShortName?: string;
openStickerSet: {
stickerSetInfo: ApiStickerSetInfo;
};
closeStickerSetModal: never;
loadStickersForEmoji: {
emoji: string;
};
clearStickersForEmoji: never;
addRecentEmoji: {
emoji: string;
};
openStickerSet: {
sticker: ApiSticker;
loadCustomEmojis: {
ids: string[];
ignoreCache?: boolean;
};
updateLastRenderedCustomEmojis: {
ids: string[];
};
openCustomEmojiSets: {
setIds: string[];
};
closeCustomEmojiSets: never;
// Bots
startBot: {
@ -1091,6 +1135,9 @@ export interface ActionPayloads {
loadPremiumStickers: {
hash?: string;
};
loadPremiumSetStickers: {
hash?: string;
};
openGiftPremiumModal: {
forUserId?: string;
@ -1107,7 +1154,7 @@ export type NonTypedActionNames = (
'init' | 'reset' | 'disconnect' | 'initApi' | 'sync' | 'saveSession' |
'showNotification' | 'dismissNotification' | 'showDialog' | 'dismissDialog' |
// ui
'toggleChatInfo' | 'setIsUiReady' | 'addRecentEmoji' | 'toggleLeftColumn' |
'toggleChatInfo' | 'setIsUiReady' | 'toggleLeftColumn' |
'toggleSafeLinkModal' | 'openHistoryCalendar' | 'closeHistoryCalendar' | 'disableContextMenuHint' |
'setNewChatMembersDialogState' | 'disableHistoryAnimations' | 'setLeftColumnWidth' | 'resetLeftColumnWidth' |
'openSeenByModal' | 'closeSeenByModal' | 'closeReactorListModal' | 'openReactorListModal' |
@ -1172,9 +1219,9 @@ export type NonTypedActionNames = (
'loadContentSettings' | 'updateContentSettings' |
'loadCountryList' | 'ensureTimeFormat' | 'loadAppConfig' |
// stickers & GIFs
'setStickerSearchQuery' | 'loadSavedGifs' | 'saveGif' | 'setGifSearchQuery' | 'searchMoreGifs' |
'faveSticker' | 'unfaveSticker' | 'toggleStickerSet' | 'loadAnimatedEmojis' | 'loadStickers' |
'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' | 'loadGreetingStickers' |
'setStickerSearchQuery' | 'saveGif' | 'setGifSearchQuery' | 'searchMoreGifs' |
'faveSticker' | 'unfaveSticker' | 'toggleStickerSet' |
'loadEmojiKeywords' |
// bots
'sendBotCommand' | 'loadTopInlineBots' | 'queryInlineBot' | 'sendInlineBotResult' |
'resetInlineBot' |

View File

@ -0,0 +1,33 @@
import { getActions, getGlobal } from '../global';
import { throttle } from '../util/schedulers';
const LOAD_QUEUE = new Set<string>();
const RENDER_HISTORY = new Set<string>();
const THROTTLE = 200;
const loadFromQueue = throttle(() => {
getActions().loadCustomEmojis({
ids: [...LOAD_QUEUE],
});
LOAD_QUEUE.clear();
}, THROTTLE, false);
const updateLastRendered = throttle(() => {
getActions().updateLastRenderedCustomEmojis({
ids: [...RENDER_HISTORY].reverse(),
});
RENDER_HISTORY.clear();
}, THROTTLE, false);
export default function useEnsureCustomEmoji(id: string) {
RENDER_HISTORY.add(id);
updateLastRendered();
if (getGlobal().customEmojis.byId[id]) {
return;
}
LOAD_QUEUE.add(id);
loadFromQueue();
}

46
src/hooks/useThumbnail.ts Normal file
View File

@ -0,0 +1,46 @@
import { useLayoutEffect, useMemo, useState } from '../lib/teact/teact';
import type { ApiMessage, ApiSticker } from '../api/types';
import { DEBUG } from '../config';
import { isWebpSupported } from '../util/environment';
import { EMPTY_IMAGE_DATA_URI, webpToPngBase64 } from '../util/webpToPng';
import { getMessageMediaThumbDataUri } from '../global/helpers';
import { selectTheme } from '../global/selectors';
import { getGlobal } from '../global';
export default function useThumbnail(media?: ApiMessage | ApiSticker) {
const isMessage = media && 'content' in media;
const thumbDataUri = isMessage ? getMessageMediaThumbDataUri(media) : media?.thumbnail?.dataUri;
const sticker = isMessage ? media.content?.sticker : media;
const shouldDecodeThumbnail = thumbDataUri && sticker && !isWebpSupported() && thumbDataUri.includes('image/webp');
const [thumbnailDecoded, setThumbnailDecoded] = useState(EMPTY_IMAGE_DATA_URI);
const id = media?.id;
useLayoutEffect(() => {
if (!shouldDecodeThumbnail) {
return;
}
webpToPngBase64(`b64-${id}`, thumbDataUri!)
.then(setThumbnailDecoded)
.catch((err) => {
if (DEBUG) {
// eslint-disable-next-line no-console
console.error(err);
}
});
}, [id, shouldDecodeThumbnail, thumbDataUri]);
// TODO Find a way to update thumbnail on theme change
const theme = selectTheme(getGlobal());
const dataUri = useMemo(() => {
const uri = shouldDecodeThumbnail ? thumbnailDecoded : thumbDataUri;
if (!uri || theme !== 'dark') return uri;
return uri.replace('<svg', '<svg fill="white"');
}, [shouldDecodeThumbnail, thumbDataUri, thumbnailDecoded, theme]);
return dataUri;
}

View File

@ -1,33 +0,0 @@
import { useLayoutEffect, useState } from '../lib/teact/teact';
import type { ApiMessage } from '../api/types';
import { DEBUG } from '../config';
import { isWebpSupported } from '../util/environment';
import { EMPTY_IMAGE_DATA_URI, webpToPngBase64 } from '../util/webpToPng';
import { getMessageMediaThumbDataUri } from '../global/helpers';
export default function useWebpThumbnail(message?: ApiMessage) {
const thumbDataUri = message && getMessageMediaThumbDataUri(message);
const sticker = message?.content?.sticker;
const shouldDecodeThumbnail = thumbDataUri && sticker && !isWebpSupported() && thumbDataUri.includes('image/webp');
const [thumbnailDecoded, setThumbnailDecoded] = useState(EMPTY_IMAGE_DATA_URI);
const messageId = message?.id;
useLayoutEffect(() => {
if (!shouldDecodeThumbnail) {
return;
}
webpToPngBase64(`b64-${messageId}`, thumbDataUri!)
.then(setThumbnailDecoded)
.catch((err) => {
if (DEBUG) {
// eslint-disable-next-line no-console
console.error(err);
}
});
}, [messageId, shouldDecodeThumbnail, thumbDataUri]);
return shouldDecodeThumbnail ? thumbnailDecoded : thumbDataUri;
}

View File

@ -1189,6 +1189,9 @@ messages.requestSimpleWebView#6abb2f73 flags:# bot:InputUser url:string theme_pa
messages.sendWebViewResultMessage#a4314f5 bot_query_id:string result:InputBotInlineResult = WebViewMessageSent;
messages.sendWebViewData#dc0242c8 bot:InputUser random_id:long button_text:string data:string = Updates;
messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.TranscribedAudio;
messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector<long> = Vector<Document>;
messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers;
messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers;
updates.getState#edd4882a = updates.State;
updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -247,10 +247,13 @@
"messages.requestSimpleWebView",
"messages.sendWebViewResultMessage",
"messages.sendWebViewData",
"messages.transcribeAudio",
"messages.getCustomEmojiDocuments",
"messages.getEmojiStickers",
"messages.getFeaturedEmojiStickers",
"messages.readReactions",
"messages.getUnreadReactions",
"messages.readMentions",
"messages.getUnreadMentions",
"help.getPremiumPromo",
"messages.transcribeAudio"
"help.getPremiumPromo"
]

View File

@ -108,7 +108,8 @@ export type TeactNode =
ReactElement
| string
| number
| boolean;
| boolean
| TeactNode[];
const Fragment = Symbol('Fragment');

View File

@ -229,10 +229,12 @@ export enum SettingsScreens {
PasscodeTurnOff,
PasscodeCongratulations,
Experimental,
Stickers,
CustomEmoji,
}
export type StickerSetOrRecent = Pick<ApiStickerSet, (
'id' | 'title' | 'count' | 'stickers' | 'hasThumbnail' | 'isLottie' | 'isVideos'
'id' | 'title' | 'count' | 'stickers' | 'hasThumbnail' | 'isLottie' | 'isVideos' | 'isEmoji' | 'installedDate'
)>;
export enum LeftColumnContent {

View File

@ -16,7 +16,7 @@ export const processDeepLink = (url: string) => {
openChatByInvite,
openChatByUsername,
openChatByPhoneNumber,
openStickerSetShortName,
openStickerSet,
focusMessage,
joinVoiceChatByLink,
openInvoice,
@ -85,8 +85,10 @@ export const processDeepLink = (url: string) => {
case 'addstickers': {
const { set } = params;
openStickerSetShortName({
stickerSetShortName: set,
openStickerSet({
stickerSetInfo: {
shortName: set,
},
});
break;
}

View File

@ -84,6 +84,10 @@ export function unique<T extends any>(array: T[]): T[] {
return Array.from(new Set(array));
}
export function uniqueByField<T extends any>(array: T[], field: keyof T): T[] {
return [...new Map(array.map((item) => [item[field], item])).values()];
}
export function compact<T extends any>(array: T[]) {
return array.filter(Boolean);
}

View File

@ -3,7 +3,7 @@ import { ApiMessageEntityTypes } from '../api/types';
import { IS_EMOJI_SUPPORTED } from './environment';
import { RE_LINK_TEMPLATE } from '../config';
const ENTITY_CLASS_BY_NODE_NAME: Record<string, string> = {
const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
B: ApiMessageEntityTypes.Bold,
STRONG: ApiMessageEntityTypes.Bold,
I: ApiMessageEntityTypes.Italic,
@ -15,6 +15,7 @@ const ENTITY_CLASS_BY_NODE_NAME: Record<string, string> = {
CODE: ApiMessageEntityTypes.Code,
PRE: ApiMessageEntityTypes.Pre,
BLOCKQUOTE: ApiMessageEntityTypes.Blockquote,
'CUSTOM-EMOJI': ApiMessageEntityTypes.CustomEmoji,
};
const MAX_TAG_DEEPNESS = 3;
@ -90,6 +91,12 @@ function parseMarkdown(html: string) {
'<code>$2</code>',
);
// Custom Emoji markdown tag
parsedHtml = parsedHtml.replace(
/(^|\s)(?!<(?:code|pre)[^<]*|<\/)\[([^\]\n]+)\]\(customEmoji:(\d+)\)(?![^<]*<\/(?:code|pre)>)(\s|$)/g,
'$1<custom-emoji document-id="$3" alt="$2">$2</custom-emoji>$4',
);
// Other simple markdown
parsedHtml = parsedHtml.replace(
/(^|\s)(?!<(code|pre)[^<]*|<\/)[*]{2}([^*\n]+)[*]{2}(?![^<]*<\/(code|pre)>)(\s|$)/g,
@ -138,18 +145,51 @@ function getEntityDataFromNode(
const offset = rawText.substring(0, index).length;
const { length } = rawText.substring(index, index + node.textContent.length);
let url: string | undefined;
let userId: string | undefined;
let language: string | undefined;
if (type === ApiMessageEntityTypes.TextUrl) {
url = (node as HTMLAnchorElement).href;
return {
index,
entity: {
type,
offset,
length,
url: (node as HTMLAnchorElement).href,
},
};
}
if (type === ApiMessageEntityTypes.MentionName) {
userId = (node as HTMLAnchorElement).dataset.userId;
return {
index,
entity: {
type,
offset,
length,
userId: (node as HTMLAnchorElement).dataset.userId!,
},
};
}
if (type === ApiMessageEntityTypes.Pre) {
language = (node as HTMLPreElement).dataset.language;
return {
index,
entity: {
type,
offset,
length,
language: (node as HTMLPreElement).dataset.language,
},
};
}
if (type === ApiMessageEntityTypes.CustomEmoji) {
return {
index,
entity: {
type,
offset,
length,
documentId: (node as HTMLElement).getAttribute('document-id')!,
},
};
}
return {
@ -158,14 +198,11 @@ function getEntityDataFromNode(
type,
offset,
length,
...(url && { url }),
...(userId && { userId }),
...(language && { language }),
},
};
}
function getEntityTypeFromNode(node: ChildNode) {
function getEntityTypeFromNode(node: ChildNode): ApiMessageEntityTypes | undefined {
if (ENTITY_CLASS_BY_NODE_NAME[node.nodeName]) {
return ENTITY_CLASS_BY_NODE_NAME[node.nodeName];
}
@ -192,7 +229,7 @@ function getEntityTypeFromNode(node: ChildNode) {
}
if (node.nodeName === 'SPAN') {
return (node as HTMLElement).dataset.entityType;
return (node as HTMLElement).dataset.entityType as any;
}
return undefined;

View File

@ -1,4 +1,4 @@
export default function insertHtmlInSelection(html: string) {
export function insertHtmlInSelection(html: string) {
const selection = window.getSelection();
if (selection?.getRangeAt && selection.rangeCount) {