diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index 7ae8f4e49..cddc3f8d4 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -110,6 +110,7 @@ export function buildStickerSet(set: GramJs.StickerSet): ApiStickerSet { count, shortName, emojis, + thumbDocumentId, } = set; return { @@ -121,7 +122,7 @@ export function buildStickerSet(set: GramJs.StickerSet): ApiStickerSet { id: String(id), accessHash: String(accessHash), title, - hasThumbnail: Boolean(thumbs?.length), + hasThumbnail: Boolean(thumbs?.length || thumbDocumentId), count, shortName, }; diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index ba2247a7d..1dedb6dd5 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -42,6 +42,7 @@ export { faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet, searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects, removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets, + fetchFeaturedEmojiStickers, } from './symbols'; export { diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index cc82c3196..9f3556230 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -28,7 +28,7 @@ export async function fetchCustomEmojiSets({ hash = '0' }: { hash?: string }) { } allStickers.sets.forEach((stickerSet) => { - if (stickerSet.thumbs?.length) { + if (stickerSet.thumbs?.length || stickerSet.thumbDocumentId) { localDb.stickerSets[String(stickerSet.id)] = stickerSet; } }); @@ -98,6 +98,25 @@ export async function fetchFeaturedStickers({ hash = '0' }: { hash?: string }) { }; } +export async function fetchFeaturedEmojiStickers() { + const result = await invokeRequest(new GramJs.messages.GetFeaturedEmojiStickers({ hash: BigInt(0) })); + + if (!result || result instanceof GramJs.messages.FeaturedStickersNotModified) { + return undefined; + } + + result.sets.forEach(({ set }) => { + if (set.thumbDocumentId) { + localDb.stickerSets[String(set.id)] = set; + } + }); + + return { + isPremium: Boolean(result.premium), + sets: result.sets.map(buildStickerSetCovered), + }; +} + export async function faveSticker({ sticker, unfave, diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index dd813d8c8..293ddf5e2 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -57,6 +57,7 @@ export { default as BotCommandTooltip } from '../components/middle/composer/BotC export { default as BotCommandMenu } from '../components/middle/composer/BotCommandMenu'; export { default as MentionTooltip } from '../components/middle/composer/MentionTooltip'; export { default as StickerTooltip } from '../components/middle/composer/StickerTooltip'; +export { default as CustomEmojiTooltip } from '../components/middle/composer/CustomEmojiTooltip'; export { default as CustomSendMenu } from '../components/middle/composer/CustomSendMenu'; export { default as DropArea } from '../components/middle/composer/DropArea'; export { default as TextFormatter } from '../components/middle/composer/TextFormatter'; diff --git a/src/components/common/AnimatedIconFromSticker.tsx b/src/components/common/AnimatedIconFromSticker.tsx index c5a3a46ac..08e9ce77a 100644 --- a/src/components/common/AnimatedIconFromSticker.tsx +++ b/src/components/common/AnimatedIconFromSticker.tsx @@ -4,6 +4,8 @@ import type { OwnProps as AnimatedIconProps } from './AnimatedIcon'; import type { ApiSticker } from '../../api/types'; import { ApiMediaFormat } from '../../api/types'; +import { getStickerPreviewHash } from '../../global/helpers'; + import useMedia from '../../hooks/useMedia'; import AnimatedIconWithPreview from './AnimatedIconWithPreview'; @@ -20,7 +22,7 @@ function AnimatedIconFromSticker(props: OwnProps) { const thumbDataUri = sticker?.thumbnail?.dataUri; const localMediaHash = `sticker${sticker?.id}`; const previewBlobUrl = useMedia( - sticker ? `${localMediaHash}?size=m` : undefined, + sticker ? getStickerPreviewHash(sticker.id) : undefined, noLoad && !forcePreview, ApiMediaFormat.BlobUrl, lastSyncTime, diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index d9a1ae00e..6bbd87ea3 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -11,14 +11,16 @@ import renderText from './helpers/renderText'; import { getPropertyHexColor } from '../../util/themeStyle'; import { hexToRgb } from '../../util/switchTheme'; import buildClassName from '../../util/buildClassName'; +import { getStickerPreviewHash } from '../../global/helpers'; import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors'; +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 safePlay from '../../util/safePlay'; +import useMediaTransition from '../../hooks/useMediaTransition'; import AnimatedSticker from './AnimatedSticker'; import OptimizedVideo from '../ui/OptimizedVideo'; @@ -28,9 +30,11 @@ import styles from './CustomEmoji.module.scss'; type OwnProps = { documentId: string; children?: TeactNode; + size?: number; className?: string; loopLimit?: number; withGridFix?: boolean; + withPreview?: boolean; observeIntersection?: ObserveFn; onClick?: NoneToVoidFunction; }; @@ -40,9 +44,11 @@ const STICKER_SIZE = 24; const CustomEmoji: FC = ({ documentId, children, + size = STICKER_SIZE, className, loopLimit, withGridFix, + withPreview, observeIntersection, onClick, }) => { @@ -51,9 +57,17 @@ const CustomEmoji: FC = ({ // An alternative to `withGlobal` to avoid adding numerous global containers const customEmoji = useCustomEmoji(documentId); const isUnsupportedVideo = customEmoji?.isVideo && !IS_WEBM_SUPPORTED; - const mediaHash = customEmoji && `sticker${customEmoji.id}${isUnsupportedVideo ? '?size=m' : ''}`; + const mediaHash = customEmoji && `sticker${customEmoji.id}`; const mediaData = useMedia(mediaHash); + + const shouldLoadPreview = !mediaData && (withPreview || isUnsupportedVideo); + const previewMediaHash = shouldLoadPreview && customEmoji && getStickerPreviewHash(customEmoji.id); + const previewMediaData = useMedia(previewMediaHash); const thumbDataUri = useThumbnail(customEmoji); + + const shouldDisplayPreview = Boolean(mediaData ? isUnsupportedVideo : previewMediaData); + const transitionClassNames = useMediaTransition(shouldDisplayPreview ? previewMediaData : mediaData); + const loopCountRef = useRef(0); const [shouldLoop, setShouldLoop] = useState(true); const [customColor, setCustomColor] = useState<[number, number, number] | undefined>(); @@ -108,15 +122,15 @@ const CustomEmoji: FC = ({ return (children && renderText(children, ['emoji'])); } - if (!mediaData) { + if (!mediaData && !previewMediaData) { return ( {customEmoji.emoji} ); } - if (isUnsupportedVideo || (!customEmoji.isVideo && !customEmoji.isLottie)) { + if (shouldDisplayPreview || isUnsupportedVideo || (!customEmoji.isVideo && !customEmoji.isLottie)) { return ( - {customEmoji.emoji} + {customEmoji.emoji} ); } @@ -137,9 +151,9 @@ const CustomEmoji: FC = ({ return ( = ({ 'emoji', hasCustomColor && 'custom-color', withGridFix && styles.withGridFix, + ...transitionClassNames, )} onClick={onClick} > diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index c7bee6fa6..690d43893 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -2,7 +2,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, } from '../../lib/teact/teact'; -import { getActions } from '../../global'; +import { getActions, getGlobal } from '../../global'; import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types'; import { ApiMediaFormat } from '../../api/types'; @@ -11,6 +11,8 @@ import buildClassName from '../../util/buildClassName'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import safePlay from '../../util/safePlay'; import { IS_TOUCH_ENV, IS_WEBM_SUPPORTED } from '../../util/environment'; +import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors'; +import { getStickerPreviewHash } from '../../global/helpers'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; @@ -76,7 +78,7 @@ const StickerButton = )} diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 56c4d6306..736d100eb 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -11,6 +11,7 @@ import buildClassName from '../../../util/buildClassName'; import renderText from './renderText'; import { copyTextToClipboard } from '../../../util/clipboard'; import { getTranslation } from '../../../util/langProvider'; +import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji'; import MentionLink from '../../middle/message/MentionLink'; import SafeLink from '../SafeLink'; @@ -480,6 +481,8 @@ function processEntityAsHtml( class="spoiler" data-entity-type="${ApiMessageEntityTypes.Spoiler}" >${renderedContent}`; + case ApiMessageEntityTypes.CustomEmoji: + return buildCustomEmojiHtmlFromEntity(rawEntityText, entity); default: return renderedContent; } diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index b46093fbf..5a67c6dfb 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -34,6 +34,7 @@ import { } from '../../../global/selectors'; import { renderActionMessageText } from '../../common/helpers/renderActionMessageText'; import renderText from '../../common/helpers/renderText'; +import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; import { fastRaf } from '../../../util/schedulers'; import buildClassName from '../../../util/buildClassName'; import { renderMessageSummary } from '../../common/helpers/renderMessageText'; @@ -249,7 +250,7 @@ const Chat: FC = ({ return (

{lang('Draft')} - {renderText(draft.text)} + {renderTextWithEntities(draft.text, draft.entities)}

); } diff --git a/src/components/left/settings/SettingsCustomEmoji.tsx b/src/components/left/settings/SettingsCustomEmoji.tsx index b5740af7c..5bb81ffc3 100644 --- a/src/components/left/settings/SettingsCustomEmoji.tsx +++ b/src/components/left/settings/SettingsCustomEmoji.tsx @@ -5,6 +5,7 @@ import { getActions, withGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; import type { ApiSticker, ApiStickerSet } from '../../../api/types'; +import type { ISettings } from '../../../types'; import renderText from '../../common/helpers/renderText'; import { pick } from '../../../util/iteratees'; @@ -14,13 +15,16 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver' import useLang from '../../../hooks/useLang'; import StickerSetCard from '../../common/StickerSetCard'; +import Checkbox from '../../ui/Checkbox'; type OwnProps = { isActive?: boolean; onReset: () => void; }; -type StateProps = { +type StateProps = Pick & { customEmojiSetIds?: string[]; stickerSetsById: Record; }; @@ -29,9 +33,10 @@ const SettingsCustomEmoji: FC = ({ isActive, customEmojiSetIds, stickerSetsById, + shouldSuggestCustomEmoji, onReset, }) => { - const { openStickerSet } = getActions(); + const { openStickerSet, setSettingOption } = getActions(); const lang = useLang(); // eslint-disable-next-line no-null/no-null @@ -49,6 +54,10 @@ const SettingsCustomEmoji: FC = ({ }); }, [openStickerSet]); + const handleSuggestCustomEmojiChange = useCallback((newValue: boolean) => { + setSettingOption({ shouldSuggestCustomEmoji: newValue }); + }, [setSettingOption]); + const customEmojiSets = useMemo(() => ( customEmojiSetIds && Object.values(pick(stickerSetsById, customEmojiSetIds)) ), [customEmojiSetIds, stickerSetsById]); @@ -57,7 +66,12 @@ const SettingsCustomEmoji: FC = ({
{customEmojiSets && (
-
+ +
{customEmojiSets.map((stickerSet: ApiStickerSet) => ( = ({ export default memo(withGlobal( (global) => { return { + ...pick(global.settings.byKey, [ + 'shouldSuggestCustomEmoji', + ]), customEmojiSetIds: global.customEmojis.added.setIds, stickerSetsById: global.stickers.setsById, }; diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 5942b4dc7..4c074f5aa 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -1,9 +1,10 @@ import React, { memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; -import type { ApiAttachment, ApiChatMember } from '../../../api/types'; +import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types'; import { CONTENT_TYPES_WITH_PREVIEW, @@ -23,6 +24,7 @@ import useLang from '../../../hooks/useLang'; import useFlag from '../../../hooks/useFlag'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import { useStateRef } from '../../../hooks/useStateRef'; +import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import Button from '../../ui/Button'; import Modal from '../../ui/Modal'; @@ -31,6 +33,7 @@ import MessageInput from './MessageInput'; import MentionTooltip from './MentionTooltip'; import EmojiTooltip from './EmojiTooltip.async'; import CustomSendMenu from './CustomSendMenu.async'; +import CustomEmojiTooltip from './CustomEmojiTooltip.async'; import './AttachmentModal.scss'; @@ -48,8 +51,9 @@ export type OwnProps = { baseEmojiKeywords?: Record; emojiKeywords?: Record; shouldSchedule?: boolean; + shouldSuggestCustomEmoji?: boolean; + customEmojiForEmoji?: ApiSticker[]; captionLimit: number; - addRecentEmoji: AnyToVoidFunction; onCaptionUpdate: (html: string) => void; onSend: () => void; onFileAppend: (files: File[], isQuick: boolean) => void; @@ -75,7 +79,8 @@ const AttachmentModal: FC = ({ baseEmojiKeywords, emojiKeywords, shouldSchedule, - addRecentEmoji, + shouldSuggestCustomEmoji, + customEmojiForEmoji, onCaptionUpdate, onSend, onFileAppend, @@ -83,6 +88,7 @@ const AttachmentModal: FC = ({ onSendSilent, onSendScheduled, }) => { + const { addRecentCustomEmoji, addRecentEmoji } = getActions(); const captionRef = useStateRef(caption); // eslint-disable-next-line no-null/no-null const mainButtonRef = useStateRef(null); @@ -106,8 +112,22 @@ const AttachmentModal: FC = ({ currentUserId, ); + const { isCustomEmojiTooltipOpen, insertCustomEmoji } = useCustomEmojiTooltip( + Boolean(shouldSuggestCustomEmoji) && isOpen, + `#${EDITABLE_INPUT_MODAL_ID}`, + caption, + onCaptionUpdate, + customEmojiForEmoji, + !isReady, + ); + const { - isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, + isEmojiTooltipOpen, + filteredEmojis, + filteredCustomEmojis, + insertEmoji, + insertCustomEmoji: insertCustomEmojiFromEmojiTooltip, + closeEmojiTooltip, } = useEmojiTooltip( isOpen, captionRef, @@ -292,9 +312,18 @@ const AttachmentModal: FC = ({ + ; emojiKeywords?: Record; topInlineBotIds?: string[]; @@ -229,6 +235,7 @@ const Composer: FC = ({ botKeyboardPlaceholder, withScheduledButton, stickersForEmoji, + customEmojiForEmoji, groupChatMembers, topInlineBotIds, currentUserId, @@ -236,6 +243,7 @@ const Composer: FC = ({ lastSyncTime, contentToBeScheduled, shouldSuggestStickers, + shouldSuggestCustomEmoji, baseEmojiKeywords, emojiKeywords, recentEmojis, @@ -271,13 +279,15 @@ const Composer: FC = ({ resetOpenChatWithDraft, callAttachBot, openLimitReachedModal, + openPremiumModal, + addRecentCustomEmoji, showNotification, } = getActions(); const lang = useLang(); // eslint-disable-next-line no-null/no-null const appendixRef = useRef(null); - const [html, setHtml] = useState(''); + const [html, setInnerHtml] = useState(''); const htmlRef = useStateRef(html); const lastMessageSendTimeSeconds = useRef(); const prevDropAreaState = usePrevious(dropAreaState); @@ -289,6 +299,15 @@ const Composer: FC = ({ const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag(); const sendMessageAction = useSendMessageAction(chatId, threadId); + const setHtml = useCallback((newHtml: string) => { + setInnerHtml(newHtml); + requestAnimationFrame(() => { + processMessageInputForCustomEmoji(); + }); + }, []); + + const customEmojiNotificationNumber = useRef(0); + const handleScheduleCancel = useCallback(() => { cancelForceShowSymbolMenu(); }, [cancelForceShowSymbolMenu]); @@ -441,8 +460,21 @@ const Composer: FC = ({ stickersForEmoji, !isReady, ); + const { isCustomEmojiTooltipOpen, closeCustomEmojiTooltip, insertCustomEmoji } = useCustomEmojiTooltip( + Boolean(shouldSuggestCustomEmoji && !attachments.length), + EDITABLE_INPUT_CSS_SELECTOR, + html, + setHtml, + customEmojiForEmoji, + !isReady, + ); const { - isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, + isEmojiTooltipOpen, + closeEmojiTooltip, + filteredEmojis, + filteredCustomEmojis, + insertEmoji, + insertCustomEmoji: insertCustomEmojiFromEmojiTooltip, } = useEmojiTooltip( Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), htmlRef, @@ -478,7 +510,7 @@ const Composer: FC = ({ requestAnimationFrame(() => { focusEditableElement(messageInput); }); - }, [htmlRef]); + }, [htmlRef, setHtml]); const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => { const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html']) @@ -487,6 +519,10 @@ const Composer: FC = ({ insertHtmlAndUpdateCursor(newHtml, inputId); }, [insertHtmlAndUpdateCursor]); + const insertCustomEmojiAndUpdateCursor = useCallback((emoji: ApiSticker, inputId: string = EDITABLE_INPUT_ID) => { + insertHtmlAndUpdateCursor(buildCustomEmojiHtml(emoji), inputId); + }, [insertHtmlAndUpdateCursor]); + const removeSymbol = useCallback(() => { const selection = window.getSelection()!; @@ -499,7 +535,7 @@ const Composer: FC = ({ } setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!)); - }, [htmlRef]); + }, [htmlRef, setHtml]); const resetComposer = useCallback((shouldPreserveInput = false) => { if (!shouldPreserveInput) { @@ -507,6 +543,7 @@ const Composer: FC = ({ } setAttachments(MEMO_EMPTY_ARRAY); closeStickerTooltip(); + closeCustomEmojiTooltip(); closeMentionTooltip(); closeEmojiTooltip(); @@ -516,7 +553,7 @@ const Composer: FC = ({ } else { closeSymbolMenu(); } - }, [closeStickerTooltip, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]); + }, [closeStickerTooltip, closeCustomEmojiTooltip, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu, setHtml]); // Handle chat change (ref is used to avoid redundant effect calls) const stopRecordingVoiceRef = useRef(); @@ -734,7 +771,33 @@ const Composer: FC = ({ focusEditableElement(messageInput, true); }); } - }, [requestedText, resetOpenChatWithDraft]); + }, [requestedText, resetOpenChatWithDraft, setHtml]); + + const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => { + if (!emoji.isFree && !isCurrentUserPremium && !isChatWithSelf) { + const notificationNumber = customEmojiNotificationNumber.current; + if (!notificationNumber) { + showNotification({ + message: lang('UnlockPremiumEmojiHint'), + action: () => openPremiumModal({ initialSection: 'animated_emoji' }), + actionText: lang('PremiumMore'), + }); + } else { + showNotification({ + message: lang('UnlockPremiumEmojiHint2'), + action: () => openChat({ id: currentUserId, shouldReplaceHistory: true }), + actionText: lang('Open'), + }); + } + customEmojiNotificationNumber.current = Number(!notificationNumber); + return; + } + + insertCustomEmojiAndUpdateCursor(emoji); + }, [ + currentUserId, insertCustomEmojiAndUpdateCursor, isChatWithSelf, isCurrentUserPremium, lang, + openChat, openPremiumModal, showNotification, + ]); const handleStickerSelect = useCallback(( sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false, @@ -1029,13 +1092,14 @@ const Composer: FC = ({ onCaptionUpdate={setHtml} baseEmojiKeywords={baseEmojiKeywords} emojiKeywords={emojiKeywords} - addRecentEmoji={addRecentEmoji} shouldSchedule={shouldSchedule} onSendSilent={handleSendSilent} onSend={handleSend} onSendScheduled={handleSendScheduled} onFileAppend={handleAppendFiles} onClear={handleClearAttachment} + shouldSuggestCustomEmoji={shouldSuggestCustomEmoji} + customEmojiForEmoji={customEmojiForEmoji} /> = ({ isOpen={isStickerTooltipOpen} onStickerSelect={handleStickerSelect} /> + = ({ onClose={closeSymbolMenu} onEmojiSelect={insertTextAndUpdateCursor} onStickerSelect={handleStickerSelect} + onCustomEmojiSelect={handleCustomEmojiSelect} onGifSelect={handleGifSelect} onRemoveSymbol={removeSymbol} onSearchOpen={handleSearchOpen} addRecentEmoji={addRecentEmoji} + addRecentCustomEmoji={addRecentCustomEmoji} />
@@ -1313,7 +1388,7 @@ export default memo(withGlobal( const isChatWithSelf = selectIsChatWithSelf(global, chatId); const messageWithActualBotKeyboard = isChatWithBot && selectNewestMessageWithBotKeyboardButtons(global, chatId); const scheduledIds = selectScheduledIds(global, chatId); - const { language, shouldSuggestStickers } = global.settings.byKey; + const { language, shouldSuggestStickers, shouldSuggestCustomEmoji } = global.settings.byKey; const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG]; const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined; @@ -1360,12 +1435,14 @@ export default memo(withGlobal( isForwarding: chatId === global.forwardMessages.toChatId, pollModal: global.pollModal, stickersForEmoji: global.stickers.forEmoji.stickers, + customEmojiForEmoji: global.customEmojis.forEmoji.stickers, groupChatMembers: chat?.fullInfo?.members, topInlineBotIds: global.topInlineBots?.userIds, currentUserId, lastSyncTime: global.lastSyncTime, contentToBeScheduled: global.messages.contentToBeScheduled, shouldSuggestStickers, + shouldSuggestCustomEmoji, recentEmojis: global.recentEmojis, baseEmojiKeywords: baseEmojiKeywords?.keywords, emojiKeywords: emojiKeywords?.keywords, diff --git a/src/components/middle/composer/CustomEmojiButton.tsx b/src/components/middle/composer/CustomEmojiButton.tsx new file mode 100644 index 000000000..6ff37568c --- /dev/null +++ b/src/components/middle/composer/CustomEmojiButton.tsx @@ -0,0 +1,46 @@ +import React, { memo, useCallback } from '../../../lib/teact/teact'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiSticker } from '../../../api/types'; + +import buildClassName from '../../../util/buildClassName'; + +import CustomEmoji from '../../common/CustomEmoji'; + +import './EmojiButton.scss'; + +const CUSTOM_EMOJI_SIZE = 32; + +type OwnProps = { + emoji: ApiSticker; + focus?: boolean; + onClick?: (emoji: ApiSticker) => void; +}; + +const CustomEmojiButton: FC = ({ + emoji, focus, onClick, +}) => { + const handleClick = useCallback((e: React.MouseEvent) => { + // Preventing safari from losing focus on Composer MessageInput + e.preventDefault(); + + onClick?.(emoji); + }, [emoji, onClick]); + + const className = buildClassName( + 'EmojiButton', + focus && 'focus', + ); + + return ( +
+ +
+ ); +}; + +export default memo(CustomEmojiButton); diff --git a/src/components/middle/composer/CustomEmojiPicker.tsx b/src/components/middle/composer/CustomEmojiPicker.tsx new file mode 100644 index 000000000..aac57ba43 --- /dev/null +++ b/src/components/middle/composer/CustomEmojiPicker.tsx @@ -0,0 +1,298 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { + useState, useEffect, memo, useRef, useMemo, useCallback, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; + +import type { ApiStickerSet, ApiSticker, ApiChat } from '../../../api/types'; +import type { StickerSetOrRecent } from '../../../types'; + +import { + CHAT_STICKER_SET_ID, + FAVORITE_SYMBOL_SET_ID, + PREMIUM_STICKER_SET_ID, + RECENT_SYMBOL_SET_ID, + SLIDE_TRANSITION_DURATION, + STICKER_SIZE_PICKER_HEADER, +} from '../../../config'; +import { IS_TOUCH_ENV } from '../../../util/environment'; +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 { selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors'; + +import useAsyncRendering from '../../right/hooks/useAsyncRendering'; +import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; +import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; +import useLang from '../../../hooks/useLang'; + +import Loading from '../../ui/Loading'; +import Button from '../../ui/Button'; +import StickerButton from '../../common/StickerButton'; +import StickerSet from './StickerSet'; +import StickerSetCover from './StickerSetCover'; +import StickerSetCoverAnimated from './StickerSetCoverAnimated'; + +import './StickerPicker.scss'; + +type OwnProps = { + chatId: string; + className: string; + loadAndPlay: boolean; + onCustomEmojiSelect: (sticker: ApiSticker) => void; +}; + +type StateProps = { + chat?: ApiChat; + stickerSetsById: Record; + addedCustomEmojiIds?: string[]; + recentCustomEmoji: ApiSticker[]; + featuredCustomEmojiIds?: string[]; + shouldPlay?: boolean; + isSavedMessages?: boolean; + isCurrentUserPremium?: boolean; +}; + +const SMOOTH_SCROLL_DISTANCE = 500; +const HEADER_BUTTON_WIDTH = 52; // px (including margin) +const STICKER_INTERSECTION_THROTTLE = 200; + +const stickerSetIntersections: boolean[] = []; + +const CustomEmojiPicker: FC = ({ + className, + loadAndPlay, + addedCustomEmojiIds, + recentCustomEmoji, + stickerSetsById, + featuredCustomEmojiIds, + shouldPlay, + isSavedMessages, + isCurrentUserPremium, + onCustomEmojiSelect, +}) => { + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const headerRef = useRef(null); + const [activeSetIndex, setActiveSetIndex] = useState(0); + + const { observe: observeIntersection } = useIntersectionObserver({ + rootRef: containerRef, + throttleMs: STICKER_INTERSECTION_THROTTLE, + }, (entries) => { + entries.forEach((entry) => { + const { id } = entry.target as HTMLDivElement; + if (!id || !id.startsWith('custom-emoji-set-')) { + return; + } + + const index = Number(id.replace('custom-emoji-set-', '')); + stickerSetIntersections[index] = entry.isIntersecting; + }); + + const intersectingWithIndexes = stickerSetIntersections + .map((isIntersecting, index) => ({ index, isIntersecting })) + .filter(({ isIntersecting }) => isIntersecting); + + if (!intersectingWithIndexes.length) { + return; + } + + setActiveSetIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index); + }); + const { observe: observeIntersectionForCovers } = useIntersectionObserver({ rootRef: headerRef }); + + const lang = useLang(); + + const areAddedLoaded = Boolean(addedCustomEmojiIds); + + const allSets = useMemo(() => { + if (!addedCustomEmojiIds) { + return MEMO_EMPTY_ARRAY; + } + + const defaultSets = []; + + if (recentCustomEmoji.length) { + defaultSets.push({ + id: RECENT_SYMBOL_SET_ID, + title: lang('RecentStickers'), + stickers: recentCustomEmoji, + count: recentCustomEmoji.length, + isEmoji: true as true, + }); + } + + const existingAddedSetIds = Object.values(pickTruthy(stickerSetsById, addedCustomEmojiIds)); + + const filteredFeaturedIds = featuredCustomEmojiIds?.filter((id) => !addedCustomEmojiIds.includes(id)) || []; + const featuredSetIds = Object.values(pickTruthy(stickerSetsById, filteredFeaturedIds)); + + return [ + ...defaultSets, + ...existingAddedSetIds, + ...featuredSetIds, + ]; + }, [addedCustomEmojiIds, featuredCustomEmojiIds, lang, recentCustomEmoji, stickerSetsById]); + + const noPopulatedSets = useMemo(() => ( + areAddedLoaded + && allSets.filter((set) => set.stickers?.length).length === 0 + ), [allSets, areAddedLoaded]); + + useHorizontalScroll(headerRef.current); + + // Scroll container and header when active set changes + useEffect(() => { + if (!areAddedLoaded) { + return; + } + + const header = headerRef.current; + if (!header) { + return; + } + + const newLeft = activeSetIndex * HEADER_BUTTON_WIDTH - (header.offsetWidth / 2 - HEADER_BUTTON_WIDTH / 2); + + fastSmoothScrollHorizontal(header, newLeft); + }, [areAddedLoaded, activeSetIndex]); + + const selectStickerSet = useCallback((index: number) => { + setActiveSetIndex(index); + const stickerSetEl = document.getElementById(`custom-emoji-set-${index}`)!; + fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE); + }, []); + + const handleEmojiSelect = useCallback((emoji: ApiSticker) => { + onCustomEmojiSelect(emoji); + }, [onCustomEmojiSelect]); + + const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION); + + function renderCover(stickerSet: StickerSetOrRecent, index: number) { + const firstSticker = stickerSet.stickers?.[0]; + const buttonClassName = buildClassName( + 'symbol-set-button sticker-set-button', + index === activeSetIndex && 'activated', + ); + + if (stickerSet.id === RECENT_SYMBOL_SET_ID + || stickerSet.id === FAVORITE_SYMBOL_SET_ID + || stickerSet.id === CHAT_STICKER_SET_ID + || stickerSet.id === PREMIUM_STICKER_SET_ID + || stickerSet.hasThumbnail + || !firstSticker) { + return ( + + ); + } else { + return ( + + ); + } + } + + const fullClassName = buildClassName('StickerPicker', 'CustomEmojiPicker', className); + + if (!areAddedLoaded || !canRenderContents || noPopulatedSets) { + return ( +
+ {noPopulatedSets ? ( +
{lang('NoStickers')}
+ ) : ( + + )} +
+ ); + } + + return ( +
+
+ {allSets.map(renderCover)} +
+
+ {allSets.map((stickerSet, i) => ( + = i - 1 && activeSetIndex <= i + 1} + onStickerSelect={handleEmojiSelect} + isSavedMessages={isSavedMessages} + isCustomEmojiPicker + isCurrentUserPremium={isCurrentUserPremium} + /> + ))} +
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const { + setsById, + } = global.stickers; + + const isSavedMessages = selectIsChatWithSelf(global, chatId); + + const recentCustomEmoji = Object.values(pickTruthy(global.customEmojis.byId, global.recentCustomEmojis)); + + return { + stickerSetsById: setsById, + addedCustomEmojiIds: global.customEmojis.added.setIds, + shouldPlay: global.settings.byKey.shouldLoopStickers, + isSavedMessages, + isCurrentUserPremium: selectIsCurrentUserPremium(global), + recentCustomEmoji, + featuredCustomEmojiIds: global.customEmojis.featuredIds, + }; + }, +)(CustomEmojiPicker)); diff --git a/src/components/middle/composer/CustomEmojiTooltip.async.tsx b/src/components/middle/composer/CustomEmojiTooltip.async.tsx new file mode 100644 index 000000000..27f721708 --- /dev/null +++ b/src/components/middle/composer/CustomEmojiTooltip.async.tsx @@ -0,0 +1,16 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { memo } from '../../../lib/teact/teact'; +import type { OwnProps } from './CustomEmojiTooltip'; +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const CustomEmojiTooltipAsync: FC = (props) => { + const { isOpen } = props; + const CustomEmojiTooltip = useModuleLoader(Bundles.Extra, 'CustomEmojiTooltip', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return CustomEmojiTooltip ? : undefined; +}; + +export default memo(CustomEmojiTooltipAsync); diff --git a/src/components/middle/composer/CustomEmojiTooltip.module.scss b/src/components/middle/composer/CustomEmojiTooltip.module.scss new file mode 100644 index 000000000..34fbafe64 --- /dev/null +++ b/src/components/middle/composer/CustomEmojiTooltip.module.scss @@ -0,0 +1,14 @@ +.root:global(.composer-tooltip) { + display: flex; + padding-left: 0.25rem; + + overflow-x: auto; + @supports (overflow-x: overlay) { + overflow-x: overlay; + } + overflow-y: hidden; + + .emojiButton { + flex: 0 0 2.5rem; + } +} diff --git a/src/components/middle/composer/CustomEmojiTooltip.tsx b/src/components/middle/composer/CustomEmojiTooltip.tsx new file mode 100644 index 000000000..c9d501687 --- /dev/null +++ b/src/components/middle/composer/CustomEmojiTooltip.tsx @@ -0,0 +1,116 @@ +import React, { + memo, useCallback, useEffect, useRef, +} from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiSticker } from '../../../api/types'; +import type { GlobalActions } from '../../../global/types'; + +import { EMOJI_SIZE_PICKER } from '../../../config'; +import { selectIsChatWithSelf, selectIsCurrentUserPremium } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import captureEscKeyListener from '../../../util/captureEscKeyListener'; + +import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; +import useShowTransition from '../../../hooks/useShowTransition'; +import usePrevious from '../../../hooks/usePrevious'; +import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; + +import Loading from '../../ui/Loading'; +import StickerButton from '../../common/StickerButton'; + +import styles from './CustomEmojiTooltip.module.scss'; + +export type OwnProps = { + chatId: string; + isOpen: boolean; + onCustomEmojiSelect: (customEmoji: ApiSticker) => void; + addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji']; +}; + +type StateProps = { + customEmoji?: ApiSticker[]; + isSavedMessages?: boolean; + isCurrentUserPremium?: boolean; +}; + +const INTERSECTION_THROTTLE = 200; + +const CustomEmojiTooltip: FC = ({ + isOpen, + customEmoji, + isSavedMessages, + isCurrentUserPremium, + onCustomEmojiSelect, + addRecentCustomEmoji, +}) => { + const { clearCustomEmojiForEmoji } = getActions(); + + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); + const prevStickers = usePrevious(customEmoji, true); + const displayedStickers = customEmoji || prevStickers; + + useHorizontalScroll(containerRef.current); + + const { + observe: observeIntersection, + } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); + + useEffect(() => ( + isOpen ? captureEscKeyListener(clearCustomEmojiForEmoji) : undefined + ), [isOpen, clearCustomEmojiForEmoji]); + + const handleCustomEmojiSelect = useCallback((ce: ApiSticker) => { + if (!isOpen) return; + onCustomEmojiSelect(ce); + addRecentCustomEmoji({ + documentId: ce.id, + }); + clearCustomEmojiForEmoji(); + }, [addRecentCustomEmoji, clearCustomEmojiForEmoji, isOpen, onCustomEmojiSelect]); + + const className = buildClassName( + styles.root, + 'composer-tooltip custom-scroll-x', + transitionClassNames, + !displayedStickers?.length && styles.hidden, + ); + + return ( +
+ {shouldRender && displayedStickers ? ( + displayedStickers.map((sticker) => ( + + )) + ) : shouldRender ? ( + + ) : undefined} +
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const { stickers: customEmoji } = global.customEmojis.forEmoji; + const isSavedMessages = selectIsChatWithSelf(global, chatId); + const isCurrentUserPremium = selectIsCurrentUserPremium(global); + return { customEmoji, isSavedMessages, isCurrentUserPremium }; + }, +)(CustomEmojiTooltip)); diff --git a/src/components/middle/composer/EmojiButton.scss b/src/components/middle/composer/EmojiButton.scss index 2f3d5a914..484fca644 100644 --- a/src/components/middle/composer/EmojiButton.scss +++ b/src/components/middle/composer/EmojiButton.scss @@ -30,4 +30,8 @@ width: 2rem; height: 2rem; } + + & > .custom-emoji { + --custom-emoji-size: 2rem; + } } diff --git a/src/components/middle/composer/EmojiButton.tsx b/src/components/middle/composer/EmojiButton.tsx index 92dc626b9..6cb97e7f3 100644 --- a/src/components/middle/composer/EmojiButton.tsx +++ b/src/components/middle/composer/EmojiButton.tsx @@ -1,6 +1,7 @@ -import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback } from '../../../lib/teact/teact'; +import type { FC } from '../../../lib/teact/teact'; + import { IS_EMOJI_SUPPORTED } from '../../../util/environment'; import { handleEmojiLoad, LOADED_EMOJIS } from '../../../util/emoji'; import buildClassName from '../../../util/buildClassName'; @@ -13,7 +14,9 @@ type OwnProps = { onClick: (emoji: string, name: string) => void; }; -const EmojiButton: FC = ({ emoji, focus, onClick }) => { +const EmojiButton: FC = ({ + emoji, focus, onClick, +}) => { const handleClick = useCallback((e: React.MouseEvent) => { // Preventing safari from losing focus on Composer MessageInput e.preventDefault(); diff --git a/src/components/middle/composer/EmojiTooltip.tsx b/src/components/middle/composer/EmojiTooltip.tsx index d884ffc07..9606b93a8 100644 --- a/src/components/middle/composer/EmojiTooltip.tsx +++ b/src/components/middle/composer/EmojiTooltip.tsx @@ -1,8 +1,10 @@ -import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; +import type { ApiSticker } from '../../../api/types'; +import type { FC } from '../../../lib/teact/teact'; + import buildClassName from '../../../util/buildClassName'; import findInViewport from '../../../util/findInViewport'; import isFullyVisible from '../../../util/isFullyVisible'; @@ -15,6 +17,7 @@ import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import Loading from '../../ui/Loading'; import EmojiButton from './EmojiButton'; +import CustomEmojiButton from './CustomEmojiButton'; import './EmojiTooltip.scss'; @@ -52,23 +55,31 @@ function setItemVisible(index: number, containerRef: Record) { export type OwnProps = { isOpen: boolean; - onEmojiSelect: (text: string) => void; - onClose: NoneToVoidFunction; - addRecentEmoji: AnyToVoidFunction; emojis: Emoji[]; + customEmojis: ApiSticker[]; + onEmojiSelect: (text: string) => void; + onCustomEmojiSelect: (emoji: ApiSticker) => void; + onClose: NoneToVoidFunction; + addRecentEmoji: ({ emoji }: { emoji: string }) => void; + addRecentCustomEmoji: ({ documentId }: { documentId: string }) => void; }; const EmojiTooltip: FC = ({ isOpen, emojis, + customEmojis, onClose, onEmojiSelect, + onCustomEmojiSelect, addRecentEmoji, + addRecentCustomEmoji, }) => { // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); - const listEmojis: Emoji[] = usePrevDuringAnimation(emojis.length ? emojis : undefined, CLOSE_DURATION) || []; + const listEmojis: (Emoji | ApiSticker)[] = usePrevDuringAnimation( + emojis.length ? [...customEmojis, ...emojis] : undefined, CLOSE_DURATION, + ) || []; useHorizontalScroll(containerRef.current); @@ -77,16 +88,34 @@ const EmojiTooltip: FC = ({ addRecentEmoji({ emoji: emoji.id }); }, [addRecentEmoji, onEmojiSelect]); + const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => { + onCustomEmojiSelect(emoji); + addRecentCustomEmoji({ documentId: emoji.id }); + }, [addRecentCustomEmoji, onCustomEmojiSelect]); + + const handleSelect = useCallback((emoji: Emoji | ApiSticker) => { + if ('native' in emoji) { + handleSelectEmoji(emoji); + } else { + handleCustomEmojiSelect(emoji); + } + }, [handleCustomEmojiSelect, handleSelectEmoji]); + const handleClick = useCallback((native: string, id: string) => { onEmojiSelect(native); addRecentEmoji({ emoji: id }); }, [addRecentEmoji, onEmojiSelect]); + const handleCustomEmojiClick = useCallback((emoji: ApiSticker) => { + onCustomEmojiSelect(emoji); + addRecentCustomEmoji({ documentId: emoji.id }); + }, [addRecentCustomEmoji, onCustomEmojiSelect]); + const selectedIndex = useKeyboardNavigation({ isActive: isOpen, isHorizontal: true, - items: emojis, - onSelect: handleSelectEmoji, + items: listEmojis, + onSelect: handleSelect, onClose, }); @@ -106,12 +135,21 @@ const EmojiTooltip: FC = ({ > {shouldRender && listEmojis ? ( listEmojis.map((emoji, index) => ( - + 'native' in emoji ? ( + + ) : ( + + ) )) ) : shouldRender ? ( diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index cdd967d19..d9221b2a9 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -297,6 +297,7 @@ const MessageInput: FC = ({ && (!textContent || !textContent.length) // When emojis are not supported, innerHTML contains an emoji img tag that doesn't exist in the textContext && !(!IS_EMOJI_SUPPORTED && innerHTML.includes('emoji-small')) + && !(innerHTML.includes('custom-emoji')) ) { const selection = window.getSelection()!; if (selection) { diff --git a/src/components/middle/composer/StickerPicker.scss b/src/components/middle/composer/StickerPicker.scss index 8ace760f2..e8a4a8a87 100644 --- a/src/components/middle/composer/StickerPicker.scss +++ b/src/components/middle/composer/StickerPicker.scss @@ -75,6 +75,10 @@ left: 0; } } + + &.activated { + background-color: var(--color-background-selected); + } } } diff --git a/src/components/middle/composer/StickerSet.tsx b/src/components/middle/composer/StickerSet.tsx index 3b4b36007..f6c27a119 100644 --- a/src/components/middle/composer/StickerSet.tsx +++ b/src/components/middle/composer/StickerSet.tsx @@ -15,6 +15,7 @@ import { import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import windowSize from '../../../util/windowSize'; import buildClassName from '../../../util/buildClassName'; +import { selectIsSetPremium } from '../../../global/selectors'; import useLang from '../../../hooks/useLang'; import useFlag from '../../../hooks/useFlag'; @@ -31,12 +32,13 @@ type OwnProps = { shouldRender: boolean; favoriteStickers?: ApiSticker[]; isSavedMessages?: boolean; + isCurrentUserPremium?: boolean; + isCustomEmojiPicker?: boolean; observeIntersection: ObserveFn; 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; @@ -52,14 +54,20 @@ const StickerSet: FC = ({ shouldRender, favoriteStickers, isSavedMessages, + isCurrentUserPremium, + isCustomEmojiPicker, observeIntersection, onStickerSelect, onStickerUnfave, onStickerFave, onStickerRemoveRecent, - isCurrentUserPremium, }) => { - const { clearRecentStickers } = getActions(); + const { + clearRecentStickers, + clearRecentCustomEmoji, + openPremiumModal, + toggleStickerSet, + } = getActions(); // eslint-disable-next-line no-null/no-null const ref = useRef(null); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); @@ -70,14 +78,32 @@ const StickerSet: FC = ({ const transitionClassNames = useMediaTransition(shouldRender); + const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID; const isEmoji = stickerSet.isEmoji; + const isPremiumSet = !isRecent && selectIsSetPremium(stickerSet); const handleClearRecent = useCallback(() => { - clearRecentStickers(); + if (isEmoji) { + clearRecentCustomEmoji(); + } else { + clearRecentStickers(); + } closeConfirmModal(); - }, [clearRecentStickers, closeConfirmModal]); + }, [clearRecentCustomEmoji, clearRecentStickers, closeConfirmModal, isEmoji]); - const isLocked = !isSavedMessages && isEmoji && !isCurrentUserPremium + const handleAddClick = useCallback(() => { + if (isPremiumSet && !isCurrentUserPremium) { + openPremiumModal({ + initialSection: 'animated_emoji', + }); + } else { + toggleStickerSet({ + stickerSetId: stickerSet.id, + }); + } + }, [isCurrentUserPremium, isPremiumSet, openPremiumModal, stickerSet, toggleStickerSet]); + + const isLocked = !isSavedMessages && !isRecent && 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; @@ -97,13 +123,11 @@ const StickerSet: FC = ({ favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined ), [favoriteStickers]); - const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID; - return (
= ({ {isRecent && ( )} + {!isRecent && isEmoji && !stickerSet.installedDate && ( + + )}
= ({ isCurrentUserPremium={isCurrentUserPremium} /> ))} - {!isExpanded && stickerSet.count > itemsBeforeCutout - 1 && ( + {!isExpanded && stickerSet.count > itemsBeforeCutout && ( diff --git a/src/components/middle/composer/StickerSetCoverAnimated.tsx b/src/components/middle/composer/StickerSetCoverAnimated.tsx index 323ee6fdc..904a2c39f 100644 --- a/src/components/middle/composer/StickerSetCoverAnimated.tsx +++ b/src/components/middle/composer/StickerSetCoverAnimated.tsx @@ -4,11 +4,12 @@ import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; import type { ApiStickerSet } from '../../../api/types'; import { STICKER_SIZE_PICKER_HEADER } from '../../../config'; +import { getFirstLetters } from '../../../util/textFormat'; + import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useMedia from '../../../hooks/useMedia'; import useMediaTransition from '../../../hooks/useMediaTransition'; -import { getFirstLetters } from '../../../util/textFormat'; import AnimatedSticker from '../../common/AnimatedSticker'; @@ -46,6 +47,7 @@ const StickerSetCoverAnimated: FC = ({ size={size} tgsUrl={lottieData} className={transitionClassNames} + play={isIntersecting} /> )}
diff --git a/src/components/middle/composer/SymbolMenu.scss b/src/components/middle/composer/SymbolMenu.scss index fc14009eb..51c46b04a 100644 --- a/src/components/middle/composer/SymbolMenu.scss +++ b/src/components/middle/composer/SymbolMenu.scss @@ -181,8 +181,8 @@ &-header { display: flex; align-items: center; + justify-content: space-between; color: rgba(var(--color-text-secondary-rgb), 0.75); - align-self: center; } &-name { @@ -196,7 +196,6 @@ text-overflow: ellipsis; text-align: center; unicode-bidi: plaintext; - flex-grow: 1; z-index: 1; background-color: var(--color-background); } diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 55c4e465e..cb7d36bd6 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -20,6 +20,7 @@ import Button from '../../ui/Button'; import Menu from '../../ui/Menu'; import Transition from '../../ui/Transition'; import EmojiPicker from './EmojiPicker'; +import CustomEmojiPicker from './CustomEmojiPicker'; import StickerPicker from './StickerPicker'; import GifPicker from './GifPicker'; import SymbolMenuFooter, { SYMBOL_MENU_TAB_TITLES, SymbolMenuTabs } from './SymbolMenuFooter'; @@ -38,6 +39,7 @@ export type OwnProps = { onLoad: () => void; onClose: () => void; onEmojiSelect: (emoji: string) => void; + onCustomEmojiSelect: (emoji: ApiSticker) => void; onStickerSelect: ( sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldPreserveInput?: boolean ) => void; @@ -45,11 +47,13 @@ export type OwnProps = { onRemoveSymbol: () => void; onSearchOpen: (type: 'stickers' | 'gifs') => void; addRecentEmoji: GlobalActions['addRecentEmoji']; + addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji']; }; type StateProps = { isLeftColumnShown: boolean; isCurrentUserPremium?: boolean; + lastSyncTime?: number; }; let isActivated = false; @@ -62,18 +66,22 @@ const SymbolMenu: FC = ({ canSendGifs, isLeftColumnShown, isCurrentUserPremium, + lastSyncTime, onLoad, onClose, onEmojiSelect, + onCustomEmojiSelect, onStickerSelect, onGifSelect, onRemoveSymbol, onSearchOpen, addRecentEmoji, + addRecentCustomEmoji, }) => { - const { loadPremiumSetStickers } = getActions(); + const { loadPremiumSetStickers, loadFeaturedEmojiStickers } = getActions(); const [activeTab, setActiveTab] = useState(0); const [recentEmojis, setRecentEmojis] = useState([]); + const [recentCustomEmojis, setRecentCustomEmojis] = useState([]); const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose, undefined, IS_SINGLE_COLUMN_LAYOUT); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, onClose, false, false); @@ -87,10 +95,12 @@ const SymbolMenu: FC = ({ }, [onLoad]); useEffect(() => { + if (!lastSyncTime) return; if (isCurrentUserPremium) { loadPremiumSetStickers(); } - }, [isCurrentUserPremium, loadPremiumSetStickers]); + loadFeaturedEmojiStickers(); + }, [isCurrentUserPremium, lastSyncTime, loadFeaturedEmojiStickers, loadPremiumSetStickers]); useLayoutEffect(() => { if (!IS_SINGLE_COLUMN_LAYOUT) { @@ -134,6 +144,28 @@ const SymbolMenu: FC = ({ onEmojiSelect(emoji); }, [onEmojiSelect]); + const recentCustomEmojisRef = useRef(recentCustomEmojis); + recentCustomEmojisRef.current = recentCustomEmojis; + useEffect(() => { + if (!recentCustomEmojisRef.current.length || isOpen) { + return; + } + + recentCustomEmojisRef.current.forEach((documentId) => { + addRecentCustomEmoji({ + documentId, + }); + }); + + setRecentEmojis([]); + }, [isOpen, addRecentCustomEmoji]); + + const handleCustomEmojiSelect = useCallback((emoji: ApiSticker) => { + setRecentCustomEmojis((ids) => [...ids, emoji.id]); + + onCustomEmojiSelect(emoji); + }, [onCustomEmojiSelect]); + const handleSearch = useCallback((type: 'stickers' | 'gifs') => { onClose(); onSearchOpen(type); @@ -154,6 +186,15 @@ const SymbolMenu: FC = ({ onEmojiSelect={handleEmojiSelect} /> ); + case SymbolMenuTabs.CustomEmoji: + return ( + + ); case SymbolMenuTabs.Stickers: return ( ( return { isLeftColumnShown: global.isLeftColumnShown, isCurrentUserPremium: selectIsCurrentUserPremium(global), + lastSyncTime: global.lastSyncTime, }; }, )(SymbolMenu)); diff --git a/src/components/middle/composer/SymbolMenuFooter.tsx b/src/components/middle/composer/SymbolMenuFooter.tsx index 1c722a573..ad157f937 100644 --- a/src/components/middle/composer/SymbolMenuFooter.tsx +++ b/src/components/middle/composer/SymbolMenuFooter.tsx @@ -14,18 +14,21 @@ type OwnProps = { export enum SymbolMenuTabs { 'Emoji', + 'CustomEmoji', 'Stickers', 'GIFs', } export const SYMBOL_MENU_TAB_TITLES: Record = { [SymbolMenuTabs.Emoji]: 'Emoji', + [SymbolMenuTabs.CustomEmoji]: 'StickersList.EmojiItem', [SymbolMenuTabs.Stickers]: 'AccDescrStickers', [SymbolMenuTabs.GIFs]: 'GifsTab', }; const SYMBOL_MENU_TAB_ICONS = { [SymbolMenuTabs.Emoji]: 'icon-smile', + [SymbolMenuTabs.CustomEmoji]: 'icon-favorite', [SymbolMenuTabs.Stickers]: 'icon-stickers', [SymbolMenuTabs.GIFs]: 'icon-gifs', }; @@ -61,7 +64,7 @@ const SymbolMenuFooter: FC = ({ return (
- {activeTab !== SymbolMenuTabs.Emoji && ( + {activeTab !== SymbolMenuTabs.Emoji && activeTab !== SymbolMenuTabs.CustomEmoji && (