diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index e5319ac88..d959dc1e6 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -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 }), }; } diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index 82b28ba6d..3cd5b4c01 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -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(Boolean as any); + return acc; + }, {} as Record); +} + +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); +} diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 3e675be58..ecfc1ffc8 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -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 }); } diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 6b3249733..f67c5d040 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -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 { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index b2ff184b8..4ba7759d7 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -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; diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index 27320b2f0..d4ff41721 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -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(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(Boolean as any); - return acc; - }, {} as Record); -} - function processGifResult(gifs: GramJs.TypeDocument[]) { return gifs .map((document) => { diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index e1ae999f1..375706084 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -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); diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 34a325efc..64cbc8e1d 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -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', } diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 5a5d10f5e..0fde9bcf5 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -366,6 +366,7 @@ export type ApiUpdateStickerSets = { export type ApiUpdateStickerSetsOrder = { '@type': 'updateStickerSetsOrder'; order: string[]; + isCustomEmoji?: boolean; }; export type ApiUpdateStickerSet = { diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index e2dcb09f9..5ce8d0baf 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/calls/group/MicrophoneButton.tsx b/src/components/calls/group/MicrophoneButton.tsx index 991537133..d1c25621d 100644 --- a/src/components/calls/group/MicrophoneButton.tsx +++ b/src/components/calls/group/MicrophoneButton.tsx @@ -124,15 +124,13 @@ const MicrophoneButton: FC = ({ 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 (
diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx new file mode 100644 index 000000000..2c8d93a8c --- /dev/null +++ b/src/components/common/CustomEmoji.tsx @@ -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 = ({ + documentId, + children, + observeIntersection, +}) => { + // eslint-disable-next-line no-null/no-null + const ref = useRef(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 ( + {customEmoji.emoji} + ); + } + if (!customEmoji.isVideo && !customEmoji.isLottie) { + return ( + {customEmoji.emoji} + ); + } + if (customEmoji.isVideo) { + return ( +
)} - -
-

{lang('AccDescrStickers')}

- - {defaultReaction && ( - onScreenSelect(SettingsScreens.QuickReaction)} - > - -
{lang('DoubleTapSetting')}
-
- )} - - - - -
- {stickerSets && stickerSets.map((stickerSet: ApiStickerSet) => ( - - ))} -
- {sticker && ( - - )} -
); }; @@ -305,15 +221,10 @@ export default memo(withGlobal( 'messageTextSize', 'animationLevel', 'messageSendKeyCombo', - 'shouldSuggestStickers', - 'shouldLoopStickers', 'isSensitiveEnabled', 'canChangeSensitive', 'timeFormat', ]), - stickerSetIds: global.stickers.added.setIds, - stickerSetsById: global.stickers.setsById, - defaultReaction: global.appConfig?.defaultReaction, theme, shouldUseSystemTheme, }; diff --git a/src/components/left/settings/SettingsHeader.tsx b/src/components/left/settings/SettingsHeader.tsx index ccc7f482c..e1ba390d7 100644 --- a/src/components/left/settings/SettingsHeader.tsx +++ b/src/components/left/settings/SettingsHeader.tsx @@ -86,6 +86,8 @@ const SettingsHeader: FC = ({ return

{lang('General')}

; case SettingsScreens.QuickReaction: return

{lang('DoubleTapSetting')}

; + case SettingsScreens.CustomEmoji: + return

{lang('Emoji')}

; case SettingsScreens.Notifications: return

{lang('Notifications')}

; case SettingsScreens.DataStorage: @@ -94,6 +96,8 @@ const SettingsHeader: FC = ({ return

{lang('PrivacySettings')}

; case SettingsScreens.Language: return

{lang('Language')}

; + case SettingsScreens.Stickers: + return

{lang('StickersName')}

; case SettingsScreens.Experimental: return

{lang('lng_settings_experimental')}

; diff --git a/src/components/left/settings/SettingsMain.tsx b/src/components/left/settings/SettingsMain.tsx index dc2437109..5b3578175 100644 --- a/src/components/left/settings/SettingsMain.tsx +++ b/src/components/left/settings/SettingsMain.tsx @@ -128,6 +128,13 @@ const SettingsMain: FC = ({ {lang('Language')} {lang.langName} + onScreenSelect(SettingsScreens.Stickers)} + > + {lang('StickersName')} + {canBuyPremium && ( } diff --git a/src/components/left/settings/SettingsStickerSet.tsx b/src/components/left/settings/SettingsStickerSet.tsx deleted file mode 100644 index 62dcc390f..000000000 --- a/src/components/left/settings/SettingsStickerSet.tsx +++ /dev/null @@ -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 = ({ - stickerSet, - observeIntersection, - onClick, -}) => { - const lang = useLang(); - - if (!stickerSet || !stickerSet.stickers) { - return undefined; - } - - const firstSticker = stickerSet.stickers?.[0]; - - if (stickerSet.hasThumbnail || !firstSticker) { - return ( - firstSticker && onClick(firstSticker)} - > - -
-
{stickerSet.title}
-
{lang('StickerPack.StickerCount', stickerSet.count, 'i')}
-
-
- ); - } else { - return ( - onClick(firstSticker)} - > - -
-
{stickerSet.title}
-
{lang('StickerPack.StickerCount', stickerSet.count, 'i')}
-
-
- ); - } -}; - -export default memo(SettingsStickerSet); diff --git a/src/components/left/settings/SettingsStickers.tsx b/src/components/left/settings/SettingsStickers.tsx new file mode 100644 index 000000000..496329bfc --- /dev/null +++ b/src/components/left/settings/SettingsStickers.tsx @@ -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 & { + addedSetIds?: string[]; + customEmojiSetIds?: string[]; + stickerSetsById: Record; + defaultReaction?: string; + }; + +const SettingsStickers: FC = ({ + 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(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 ( +
+
+ + + onScreenSelect(SettingsScreens.CustomEmoji)} + icon="smile" + > + {lang('StickersList.EmojiItem')} + {customEmojiSetIds && {customEmojiSetIds.length}} + + {defaultReaction && ( + onScreenSelect(SettingsScreens.QuickReaction)} + > + +
{lang('DoubleTapSetting')}
+
+ )} +
+ {stickerSets && ( +
+

+ {lang('ChooseStickerMyStickerSets')} +

+
+ {stickerSets.map((stickerSet: ApiStickerSet) => ( + + ))} +
+

+ {renderText(lang('StickersBotInfo'), ['links'])} +

+
+ )} +
+ ); +}; + +export default memo(withGlobal( + (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)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index d93d9ee9d..a700418ec 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -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 = ({ shouldSkipHistoryAnimations, limitReached, openedStickerSetShortName, + openedCustomEmojiSetIds, isServiceChatReady, animationLevel, language, wasTimeFormatSetManually, addedSetIds, + addedCustomEmojiIds, isPhoneCallActive, newContactUserId, newContactByPhoneNumber, @@ -173,11 +180,13 @@ const Main: FC = ({ loadAddedStickers, loadFavoriteStickers, ensureTimeFormat, - openStickerSetShortName, + closeStickerSetModal, + closeCustomEmojiSets, checkVersionNotification, loadAppConfig, loadAttachMenuBots, loadContactList, + loadCustomEmojis, closePaymentModal, clearReceipt, } = getActions(); @@ -226,17 +235,29 @@ const Main: FC = ({ } }, [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 = ({ }, [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 = ({ onClose={handleStickerSetModalClose} stickerSetShortName={openedStickerSetShortName} /> + {activeGroupCallId && } = ({ onSelectSlide={handleSelectSlide} /> diff --git a/src/components/mediaViewer/MediaViewerFooter.scss b/src/components/mediaViewer/MediaViewerFooter.scss index 5919e8212..a1e06145d 100644 --- a/src/components/mediaViewer/MediaViewerFooter.scss +++ b/src/components/mediaViewer/MediaViewerFooter.scss @@ -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: ""; diff --git a/src/components/middle/HeaderPinnedMessage.tsx b/src/components/middle/HeaderPinnedMessage.tsx index 8035c7a8e..d9fe85d56 100644 --- a/src/components/middle/HeaderPinnedMessage.tsx +++ b/src/components/middle/HeaderPinnedMessage.tsx @@ -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 = ({ }) => { 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)); diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 317f36b50..fc49c0a64 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -132,7 +132,7 @@ const MessageListContent: FC = ({ && 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 diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index cc75fedbb..477cba06b 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -520,6 +520,10 @@ body.is-ios & { font-size: 0.9375rem; } + + .text-entity-custom-emoji { + --custom-emoji-size: 1.125rem; + } } } diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index f33411077..5942b4dc7 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -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; emojiKeywords?: Record; 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 = ({ undefined, currentUserId, ); + const { isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, } = useEmojiTooltip( diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index eb969e2cc..6933f4835 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -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 = ({ !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 = ({ } 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 = ({ }); }, [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 = ({ isDisabled={Boolean(activeVoiceRecording)} /> )} - {isChatWithBot && isBotMenuButtonCommands && botCommands !== false && !activeVoiceRecording - && !editingMessage && ( + {(isChatWithBot && isBotMenuButtonCommands + && botCommands !== false && !activeVoiceRecording && !editingMessage) && ( ( 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; diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index 65ec66487..a73e18366 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -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 = ({ noAuthors, noCaptions, forwardsHaveCaptions, + isCurrentUserPremium, onClear, }) => { const { @@ -159,6 +163,23 @@ const ComposerEmbeddedMessage: FC = ({ ? 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 = ({ ( noAuthors, noCaptions, forwardsHaveCaptions, + isCurrentUserPremium: selectIsCurrentUserPremium(global), }; }, )(ComposerEmbeddedMessage)); diff --git a/src/components/middle/composer/EmojiButton.scss b/src/components/middle/composer/EmojiButton.scss index 58ef8837f..2f3d5a914 100644 --- a/src/components/middle/composer/EmojiButton.scss +++ b/src/components/middle/composer/EmojiButton.scss @@ -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; } diff --git a/src/components/middle/composer/EmojiCategory.tsx b/src/components/middle/composer/EmojiCategory.tsx index 13767d61d..ce2856538 100644 --- a/src/components/middle/composer/EmojiCategory.tsx +++ b/src/components/middle/composer/EmojiCategory.tsx @@ -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; diff --git a/src/components/middle/composer/EmojiPicker.scss b/src/components/middle/composer/EmojiPicker.scss index 793b7a3e7..ae0789fb4 100644 --- a/src/components/middle/composer/EmojiPicker.scss +++ b/src/components/middle/composer/EmojiPicker.scss @@ -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; diff --git a/src/components/middle/composer/EmojiPicker.tsx b/src/components/middle/composer/EmojiPicker.tsx index 073985159..03519e014 100644 --- a/src/components/middle/composer/EmojiPicker.tsx +++ b/src/components/middle/composer/EmojiPicker.tsx @@ -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; + type EmojiCategoryData = { id: string; name: string; emojis: string[] }; const ICONS_BY_CATEGORY: Record = { @@ -66,7 +67,9 @@ let emojiRawData: EmojiRawData; let emojiData: EmojiData; const EmojiPicker: FC = ({ - className, onEmojiSelect, recentEmojis, + className, + recentEmojis, + onEmojiSelect, }) => { // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index c59796bb2..5486e0ba3 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -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; addedSetIds?: string[]; shouldPlay?: boolean; @@ -74,6 +75,7 @@ const StickerPicker: FC = ({ canSendStickers, recentStickers, favoriteStickers, + premiumStickers, addedSetIds, stickerSetsById, shouldPlay, @@ -155,17 +157,19 @@ const StickerPicker: FC = ({ } 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 = ({ ...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 = ({ onClick={() => selectStickerSet(index)} > {stickerSet.id === PREMIUM_STICKER_SET_ID ? ( - + ) : stickerSet.id === RECENT_SYMBOL_SET_ID ? ( ) : stickerSet.id === FAVORITE_SYMBOL_SET_ID ? ( @@ -370,6 +374,7 @@ export default memo(withGlobal( added, recent, favorite, + premiumSet, } = global.stickers; const isSavedMessages = selectIsChatWithSelf(global, chatId); @@ -379,6 +384,7 @@ export default memo(withGlobal( chat, recentStickers: recent.stickers, favoriteStickers: favorite.stickers, + premiumStickers: premiumSet.stickers, stickerSetsById: setsById, addedSetIds: added.setIds, shouldPlay: global.settings.byKey.shouldLoopStickers, diff --git a/src/components/middle/composer/StickerSet.tsx b/src/components/middle/composer/StickerSet.tsx index 3d4488025..3b4b36007 100644 --- a/src/components/middle/composer/StickerSet.tsx +++ b/src/components/middle/composer/StickerSet.tsx @@ -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 = ({ @@ -58,21 +63,35 @@ const StickerSet: FC = ({ // eslint-disable-next-line no-null/no-null const ref = useRef(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 = ({ ref={ref} key={stickerSet.id} id={`sticker-set-${index}`} - className="symbol-set" + className={ + buildClassName('symbol-set', isLocked && 'symbol-set-locked') + } >
-

{stickerSet.title}

+

+ {isLocked && } + {stickerSet.title} +

{isRecent && ( )} @@ -97,29 +121,36 @@ const StickerSet: FC = ({ className={buildClassName('symbol-set-container', transitionClassNames)} style={`height: ${height}px;`} > - {shouldRender && stickerSet.stickers && stickerSet.stickers.map((sticker) => ( - - ))} + {shouldRender && stickerSet.stickers && stickerSet.stickers + .slice(0, !isExpanded ? (itemsBeforeCutout - 1) : stickerSet.stickers.length) + .map((sticker) => ( + + ))} + {!isExpanded && stickerSet.count > itemsBeforeCutout - 1 && ( + + )}
{isRecent && ( = ({ sticker={sticker} size={STICKER_SIZE_PICKER} observeIntersection={observeIntersection} - onClick={onStickerSelect} + onClick={isOpen ? onStickerSelect : undefined} clickArg={sticker} isSavedMessages={isSavedMessages} canViewSet diff --git a/src/components/middle/composer/SymbolMenu.scss b/src/components/middle/composer/SymbolMenu.scss index 750dd0e8e..fc14009eb 100644 --- a/src/components/middle/composer/SymbolMenu.scss +++ b/src/components/middle/composer/SymbolMenu.scss @@ -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,"); + } &-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; } diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 0115eb9a2..55c4e465e 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -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 = ({ canSendStickers, canSendGifs, isLeftColumnShown, + isCurrentUserPremium, onLoad, onClose, onEmojiSelect, @@ -66,6 +71,7 @@ const SymbolMenu: FC = ({ onSearchOpen, addRecentEmoji, }) => { + const { loadPremiumSetStickers } = getActions(); const [activeTab, setActiveTab] = useState(0); const [recentEmojis, setRecentEmojis] = useState([]); @@ -80,6 +86,12 @@ const SymbolMenu: FC = ({ onLoad(); }, [onLoad]); + useEffect(() => { + if (isCurrentUserPremium) { + loadPremiumSetStickers(); + } + }, [isCurrentUserPremium, loadPremiumSetStickers]); + useLayoutEffect(() => { if (!IS_SINGLE_COLUMN_LAYOUT) { return undefined; @@ -105,7 +117,7 @@ const SymbolMenu: FC = ({ 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 = ({ }); 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 = ({ <>
{isActivated && ( - + {renderContent} )} @@ -246,6 +256,7 @@ export default memo(withGlobal( (global): StateProps => { return { isLeftColumnShown: global.isLeftColumnShown, + isCurrentUserPremium: selectIsCurrentUserPremium(global), }; }, )(SymbolMenu)); diff --git a/src/components/middle/composer/SymbolMenuFooter.tsx b/src/components/middle/composer/SymbolMenuFooter.tsx index d7d9b8be1..1c722a573 100644 --- a/src/components/middle/composer/SymbolMenuFooter.tsx +++ b/src/components/middle/composer/SymbolMenuFooter.tsx @@ -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.Emoji]: 'Emoji', + [SymbolMenuTabs.Stickers]: 'AccDescrStickers', + [SymbolMenuTabs.GIFs]: 'GifsTab', +}; const SYMBOL_MENU_TAB_ICONS = { [SymbolMenuTabs.Emoji]: 'icon-smile', @@ -40,7 +41,7 @@ const SymbolMenuFooter: FC = ({ 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" diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index 86e68bd7d..ba9f8cd3e 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -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 = ({ 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; } diff --git a/src/components/middle/composer/hooks/useStickerTooltip.ts b/src/components/middle/composer/hooks/useStickerTooltip.ts index 4efb73731..2591931af 100644 --- a/src/components/middle/composer/hooks/useStickerTooltip.ts +++ b/src/components/middle/composer/hooks/useStickerTooltip.ts @@ -20,14 +20,14 @@ export default function useStickerTooltip( (IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1) || (!IS_EMOJI_SUPPORTED && Boolean(html.match(/^]*?>$/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(); diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 49f893448..64876d887 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -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 = ({ messageListType, chatUsername, message, + customEmojiSetsInfo, + customEmojiSets, album, anchor, onClose, @@ -143,6 +151,7 @@ const ContextMenuContainer: FC = ({ loadReactors, copyMessagesByIds, saveGif, + loadStickers, cancelPollVote, closePoll, } = getActions(); @@ -156,6 +165,9 @@ const ContextMenuContainer: FC = ({ 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 = ({ } }, [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 = ({ canRevote={canRevote} canClosePoll={canClosePoll} canShowSeenBy={canShowSeenBy} + hasCustomEmoji={hasCustomEmoji} + customEmojiSets={customEmojiSets} isDownloading={isDownloading} seenByRecentUsers={seenByRecentUsers} onReply={handleReply} @@ -516,6 +538,11 @@ export default memo(withGlobal( 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(Boolean) + ? customEmojiSetsNotFiltered : undefined; + return { availableReactions: global.availableReactions, noOptions, @@ -547,6 +574,8 @@ export default memo(withGlobal( canShowReactionList: !isLocal && !isAction && !isScheduled && chat?.id !== SERVICE_NOTIFICATIONS_USER_ID, canRemoveReaction, canBuyPremium: !isCurrentUserPremium && !selectIsPremiumPurchaseBlocked(global), + customEmojiSetsInfo, + customEmojiSets, }; }, )(ContextMenuContainer)); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 0b4ac3b7f..781d4e35b 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -507,7 +507,13 @@ const Message: FC = ({ 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; diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index efb99575f..1cbead89a 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -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 = ({ canRemoveReaction, canShowReactionList, seenByRecentUsers, + hasCustomEmoji, + customEmojiSets, onReply, onEdit, onPin, @@ -151,7 +158,7 @@ const MessageContextMenu: FC = ({ onAboutAds, onSponsoredHide, }) => { - const { showNotification } = getActions(); + const { showNotification, openStickerSet, openCustomEmojiSets } = getActions(); // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); // eslint-disable-next-line no-null/no-null @@ -171,6 +178,22 @@ const MessageContextMenu: FC = ({ 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 = ({ )} {canDelete && {lang('Delete')}} + {hasCustomEmoji && ( + <> + + {!customEmojiSets && ( + <> + + + + )} + {customEmojiSets && customEmojiSets.length === 1 && ( + + {renderText(lang('MessageContainsEmojiPack', customEmojiSets[0].title), ['simple_markdown', 'emoji'])} + + )} + {customEmojiSets && customEmojiSets.length > 1 && ( + + {renderText(lang('MessageContainsEmojiPacks', customEmojiSets.length), ['simple_markdown'])} + + )} + + )} {isSponsoredMessage && {lang('SponsoredMessageInfo')}} {isSponsoredMessage && onSponsoredHide && ( {lang('HideAd')} diff --git a/src/components/middle/message/Sticker.tsx b/src/components/middle/message/Sticker.tsx index d8add7716..63507174f 100644 --- a/src/components/middle/message/Sticker.tsx +++ b/src/components/middle/message/Sticker.tsx @@ -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 = ({ 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(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 = ({ 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 = ({ } }, [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 = ({ onEnded={handleEffectEnded} /> )} -
); }; diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index 8c27a888b..0da949bb7 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -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); diff --git a/src/components/payment/PaymentModal.tsx b/src/components/payment/PaymentModal.tsx index 26b461d8e..41cb4d9e5 100644 --- a/src/components/payment/PaymentModal.tsx +++ b/src/components/payment/PaymentModal.tsx @@ -317,14 +317,9 @@ const PaymentModal: FC = ({ } }, [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); diff --git a/src/components/right/StickerSearch.tsx b/src/components/right/StickerSearch.tsx index a459346d4..31a94bffc 100644 --- a/src/components/right/StickerSearch.tsx +++ b/src/components/right/StickerSearch.tsx @@ -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 = ({ - onClose, isActive, query, featuredIds, resultIds, + isModalOpen, + onClose, }) => { const { loadFeaturedStickers } = getActions(); @@ -44,8 +46,6 @@ const StickerSearch: FC = ({ const lang = useLang(); - const [isModalOpen, setIsModalOpen] = useState(false); - const { observe: observeIntersection, } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); @@ -74,8 +74,7 @@ const StickerSearch: FC = ({ key={id} stickerSetId={id} observeIntersection={observeIntersection} - isSomeModalOpen={isModalOpen} - onModalToggle={setIsModalOpen} + isModalOpen={isModalOpen} /> )); } @@ -90,8 +89,7 @@ const StickerSearch: FC = ({ 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)); diff --git a/src/components/right/StickerSetResult.tsx b/src/components/right/StickerSetResult.tsx index 386104868..613dbe2e3 100644 --- a/src/components/right/StickerSetResult.tsx +++ b/src/components/right/StickerSetResult.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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} /> ))} - {canRenderStickers && ( - - )} ); }; diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index d979bb512..f94ebd5fc 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -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); + } } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 3f82f6f96..2cb3d207f 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -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 = ({ isText, isLoading, isShiny, + withPremiumGradient, ariaLabel, ariaControls, hasPopup, @@ -114,6 +116,7 @@ const Button: FC = ({ isClicked && 'clicked', backgroundImage && 'with-image', isShiny && 'shiny', + withPremiumGradient && 'premium', ); const handleClick = useCallback((e: ReactMouseEvent) => { diff --git a/src/components/ui/Menu.scss b/src/components/ui/Menu.scss index 1073c2b96..8676a774a 100644 --- a/src/components/ui/Menu.scss +++ b/src/components/ui/Menu.scss @@ -94,4 +94,9 @@ background: var(--color-background); } } + + .menu-loading-row { + margin: 0.125rem 1rem; + width: calc(100% - 2rem); + } } diff --git a/src/components/ui/MenuItem.scss b/src/components/ui/MenuItem.scss index aac7706b6..8141a5b79 100644 --- a/src/components/ui/MenuItem.scss +++ b/src/components/ui/MenuItem.scss @@ -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; + } } diff --git a/src/components/ui/MenuItem.tsx b/src/components/ui/MenuItem.tsx index 73f054803..9b0170468 100644 --- a/src/components/ui/MenuItem.tsx +++ b/src/components/ui/MenuItem.tsx @@ -22,6 +22,7 @@ type OwnProps = { disabled?: boolean; destructive?: boolean; ariaLabel?: string; + withWrap?: boolean; }; const MenuItem: FC = (props) => { @@ -36,6 +37,7 @@ const MenuItem: FC = (props) => { disabled, destructive, ariaLabel, + withWrap, onContextMenu, } = props; @@ -72,6 +74,7 @@ const MenuItem: FC = (props) => { disabled && 'disabled', destructive && 'destructive', IS_COMPACT_MENU && 'compact', + withWrap && 'wrap', ); const content = ( diff --git a/src/components/ui/Skeleton.scss b/src/components/ui/Skeleton.scss index 8bad84a98..b50827e04 100644 --- a/src/components/ui/Skeleton.scss +++ b/src/components/ui/Skeleton.scss @@ -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%); diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx index bbd901b77..17ead61a6 100644 --- a/src/components/ui/Skeleton.tsx +++ b/src/components/ui/Skeleton.tsx @@ -12,6 +12,7 @@ type OwnProps = { width?: number; height?: number; forceAspectRatio?: boolean; + inline?: boolean; className?: string; }; @@ -21,14 +22,15 @@ const Skeleton: FC = ({ 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 ( -
+
{inline && '\u00A0'}
); }; diff --git a/src/config.ts b/src/config.ts index d265915c9..9997a530f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 0cc9c990c..44e23a6dc 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -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, + }, }); } diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 44ecb1084..d1f390285 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -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 }); diff --git a/src/global/actions/api/symbols.ts b/src/global/actions/api/symbols.ts index 28ef0aa14..807761652 100644 --- a/src/global/actions/api/symbols.ts +++ b/src/global/actions/api/symbols.ts @@ -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; } diff --git a/src/global/actions/api/sync.ts b/src/global/actions/api/sync.ts index 3ed2eeb73..6bc38d998 100644 --- a/src/global/actions/api/sync.ts +++ b/src/global/actions/api/sync.ts @@ -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; diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index 2c871e6a4..a13c06be7 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -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': diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index dc8658e75..8c50653c5 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -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), + }, + }; +}); diff --git a/src/global/cache.ts b/src/global/cache.ts index ef47bda1b..a5ab32791 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -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 diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 7c0ec485d..62142d31a 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -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); } } diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 034ce7104..c50c92ef1 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -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: { diff --git a/src/global/reducers/symbols.ts b/src/global/reducers/symbols.ts index c563a5a6d..c86bbaa03 100644 --- a/src/global/reducers/symbols.ts +++ b/src/global/reducers/symbols.ts @@ -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, ): 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; } diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index a90b9e8fa..aec749cd0 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -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; diff --git a/src/global/selectors/symbols.ts b/src/global/selectors/symbols.ts index 8ea9ae396..649398d0e 100644 --- a/src/global/selectors/symbols.ts +++ b/src/global/selectors/symbols.ts @@ -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', ''); diff --git a/src/global/types.ts b/src/global/types.ts index 1c0170766..cf5f7538b 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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; @@ -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; + }; + animatedEmojis?: ApiStickerSet; animatedEmojiEffects?: ApiStickerSet; premiumGifts?: ApiStickerSet; @@ -542,6 +557,7 @@ export type GlobalState = { safeLinkModalUrl?: string; historyCalendarSelectedAt?: number; openedStickerSetShortName?: string; + openedCustomEmojiSetIds?: string[]; activeDownloads: { byChatId: Record; @@ -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' | diff --git a/src/hooks/useEnsureCustomEmoji.ts b/src/hooks/useEnsureCustomEmoji.ts new file mode 100644 index 000000000..16b30b93e --- /dev/null +++ b/src/hooks/useEnsureCustomEmoji.ts @@ -0,0 +1,33 @@ +import { getActions, getGlobal } from '../global'; +import { throttle } from '../util/schedulers'; + +const LOAD_QUEUE = new Set(); +const RENDER_HISTORY = new Set(); +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(); +} diff --git a/src/hooks/useThumbnail.ts b/src/hooks/useThumbnail.ts new file mode 100644 index 000000000..ef38e4fd5 --- /dev/null +++ b/src/hooks/useThumbnail.ts @@ -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(' { - 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; -} diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index cda9d0ff2..0105e2186 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -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 = Vector; +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; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 5977e12db..ad67e3aa8 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -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" ] diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts index 61ffe7f73..60a60e33b 100644 --- a/src/lib/teact/teact.ts +++ b/src/lib/teact/teact.ts @@ -108,7 +108,8 @@ export type TeactNode = ReactElement | string | number - | boolean; + | boolean + | TeactNode[]; const Fragment = Symbol('Fragment'); diff --git a/src/types/index.ts b/src/types/index.ts index 8f36fb480..e052df21b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -229,10 +229,12 @@ export enum SettingsScreens { PasscodeTurnOff, PasscodeCongratulations, Experimental, + Stickers, + CustomEmoji, } export type StickerSetOrRecent = Pick; export enum LeftColumnContent { diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index f3d6cd91c..64da612d5 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -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; } diff --git a/src/util/iteratees.ts b/src/util/iteratees.ts index 40344295e..caf610bd4 100644 --- a/src/util/iteratees.ts +++ b/src/util/iteratees.ts @@ -84,6 +84,10 @@ export function unique(array: T[]): T[] { return Array.from(new Set(array)); } +export function uniqueByField(array: T[], field: keyof T): T[] { + return [...new Map(array.map((item) => [item[field], item])).values()]; +} + export function compact(array: T[]) { return array.filter(Boolean); } diff --git a/src/util/parseMessageInput.ts b/src/util/parseMessageInput.ts index 66a24a9df..f902fd193 100644 --- a/src/util/parseMessageInput.ts +++ b/src/util/parseMessageInput.ts @@ -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 = { +const ENTITY_CLASS_BY_NODE_NAME: Record = { B: ApiMessageEntityTypes.Bold, STRONG: ApiMessageEntityTypes.Bold, I: ApiMessageEntityTypes.Italic, @@ -15,6 +15,7 @@ const ENTITY_CLASS_BY_NODE_NAME: Record = { 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) { '$2', ); + // Custom Emoji markdown tag + parsedHtml = parsedHtml.replace( + /(^|\s)(?!<(?:code|pre)[^<]*|<\/)\[([^\]\n]+)\]\(customEmoji:(\d+)\)(?![^<]*<\/(?:code|pre)>)(\s|$)/g, + '$1$2$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; diff --git a/src/util/insertHtmlInSelection.ts b/src/util/selection.ts similarity index 89% rename from src/util/insertHtmlInSelection.ts rename to src/util/selection.ts index 93ec7d19f..47b37f85a 100644 --- a/src/util/insertHtmlInSelection.ts +++ b/src/util/selection.ts @@ -1,4 +1,4 @@ -export default function insertHtmlInSelection(html: string) { +export function insertHtmlInSelection(html: string) { const selection = window.getSelection(); if (selection?.getRangeAt && selection.rangeCount) {