Support sending custom emoji (#2000)

This commit is contained in:
Alexander Zinchuk 2022-10-29 15:18:42 +02:00
parent 76c1816eba
commit c365881d57
54 changed files with 1333 additions and 98 deletions

View File

@ -110,6 +110,7 @@ export function buildStickerSet(set: GramJs.StickerSet): ApiStickerSet {
count, count,
shortName, shortName,
emojis, emojis,
thumbDocumentId,
} = set; } = set;
return { return {
@ -121,7 +122,7 @@ export function buildStickerSet(set: GramJs.StickerSet): ApiStickerSet {
id: String(id), id: String(id),
accessHash: String(accessHash), accessHash: String(accessHash),
title, title,
hasThumbnail: Boolean(thumbs?.length), hasThumbnail: Boolean(thumbs?.length || thumbDocumentId),
count, count,
shortName, shortName,
}; };

View File

@ -42,6 +42,7 @@ export {
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet, faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects, searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets, removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets,
fetchFeaturedEmojiStickers,
} from './symbols'; } from './symbols';
export { export {

View File

@ -28,7 +28,7 @@ export async function fetchCustomEmojiSets({ hash = '0' }: { hash?: string }) {
} }
allStickers.sets.forEach((stickerSet) => { allStickers.sets.forEach((stickerSet) => {
if (stickerSet.thumbs?.length) { if (stickerSet.thumbs?.length || stickerSet.thumbDocumentId) {
localDb.stickerSets[String(stickerSet.id)] = stickerSet; 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({ export async function faveSticker({
sticker, sticker,
unfave, unfave,

View File

@ -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 BotCommandMenu } from '../components/middle/composer/BotCommandMenu';
export { default as MentionTooltip } from '../components/middle/composer/MentionTooltip'; export { default as MentionTooltip } from '../components/middle/composer/MentionTooltip';
export { default as StickerTooltip } from '../components/middle/composer/StickerTooltip'; 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 CustomSendMenu } from '../components/middle/composer/CustomSendMenu';
export { default as DropArea } from '../components/middle/composer/DropArea'; export { default as DropArea } from '../components/middle/composer/DropArea';
export { default as TextFormatter } from '../components/middle/composer/TextFormatter'; export { default as TextFormatter } from '../components/middle/composer/TextFormatter';

View File

@ -4,6 +4,8 @@ import type { OwnProps as AnimatedIconProps } from './AnimatedIcon';
import type { ApiSticker } from '../../api/types'; import type { ApiSticker } from '../../api/types';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
import { getStickerPreviewHash } from '../../global/helpers';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import AnimatedIconWithPreview from './AnimatedIconWithPreview'; import AnimatedIconWithPreview from './AnimatedIconWithPreview';
@ -20,7 +22,7 @@ function AnimatedIconFromSticker(props: OwnProps) {
const thumbDataUri = sticker?.thumbnail?.dataUri; const thumbDataUri = sticker?.thumbnail?.dataUri;
const localMediaHash = `sticker${sticker?.id}`; const localMediaHash = `sticker${sticker?.id}`;
const previewBlobUrl = useMedia( const previewBlobUrl = useMedia(
sticker ? `${localMediaHash}?size=m` : undefined, sticker ? getStickerPreviewHash(sticker.id) : undefined,
noLoad && !forcePreview, noLoad && !forcePreview,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
lastSyncTime, lastSyncTime,

View File

@ -11,14 +11,16 @@ import renderText from './helpers/renderText';
import { getPropertyHexColor } from '../../util/themeStyle'; import { getPropertyHexColor } from '../../util/themeStyle';
import { hexToRgb } from '../../util/switchTheme'; import { hexToRgb } from '../../util/switchTheme';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { getStickerPreviewHash } from '../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors'; import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors';
import safePlay from '../../util/safePlay';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji'; import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useThumbnail from '../../hooks/useThumbnail'; import useThumbnail from '../../hooks/useThumbnail';
import useCustomEmoji from './hooks/useCustomEmoji'; import useCustomEmoji from './hooks/useCustomEmoji';
import safePlay from '../../util/safePlay'; import useMediaTransition from '../../hooks/useMediaTransition';
import AnimatedSticker from './AnimatedSticker'; import AnimatedSticker from './AnimatedSticker';
import OptimizedVideo from '../ui/OptimizedVideo'; import OptimizedVideo from '../ui/OptimizedVideo';
@ -28,9 +30,11 @@ import styles from './CustomEmoji.module.scss';
type OwnProps = { type OwnProps = {
documentId: string; documentId: string;
children?: TeactNode; children?: TeactNode;
size?: number;
className?: string; className?: string;
loopLimit?: number; loopLimit?: number;
withGridFix?: boolean; withGridFix?: boolean;
withPreview?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onClick?: NoneToVoidFunction; onClick?: NoneToVoidFunction;
}; };
@ -40,9 +44,11 @@ const STICKER_SIZE = 24;
const CustomEmoji: FC<OwnProps> = ({ const CustomEmoji: FC<OwnProps> = ({
documentId, documentId,
children, children,
size = STICKER_SIZE,
className, className,
loopLimit, loopLimit,
withGridFix, withGridFix,
withPreview,
observeIntersection, observeIntersection,
onClick, onClick,
}) => { }) => {
@ -51,9 +57,17 @@ const CustomEmoji: FC<OwnProps> = ({
// An alternative to `withGlobal` to avoid adding numerous global containers // An alternative to `withGlobal` to avoid adding numerous global containers
const customEmoji = useCustomEmoji(documentId); const customEmoji = useCustomEmoji(documentId);
const isUnsupportedVideo = customEmoji?.isVideo && !IS_WEBM_SUPPORTED; 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 mediaData = useMedia(mediaHash);
const shouldLoadPreview = !mediaData && (withPreview || isUnsupportedVideo);
const previewMediaHash = shouldLoadPreview && customEmoji && getStickerPreviewHash(customEmoji.id);
const previewMediaData = useMedia(previewMediaHash);
const thumbDataUri = useThumbnail(customEmoji); const thumbDataUri = useThumbnail(customEmoji);
const shouldDisplayPreview = Boolean(mediaData ? isUnsupportedVideo : previewMediaData);
const transitionClassNames = useMediaTransition(shouldDisplayPreview ? previewMediaData : mediaData);
const loopCountRef = useRef(0); const loopCountRef = useRef(0);
const [shouldLoop, setShouldLoop] = useState(true); const [shouldLoop, setShouldLoop] = useState(true);
const [customColor, setCustomColor] = useState<[number, number, number] | undefined>(); const [customColor, setCustomColor] = useState<[number, number, number] | undefined>();
@ -108,15 +122,15 @@ const CustomEmoji: FC<OwnProps> = ({
return (children && renderText(children, ['emoji'])); return (children && renderText(children, ['emoji']));
} }
if (!mediaData) { if (!mediaData && !previewMediaData) {
return ( return (
<img className={styles.media} src={thumbDataUri} alt={customEmoji.emoji} /> <img className={styles.media} src={thumbDataUri} alt={customEmoji.emoji} />
); );
} }
if (isUnsupportedVideo || (!customEmoji.isVideo && !customEmoji.isLottie)) { if (shouldDisplayPreview || isUnsupportedVideo || (!customEmoji.isVideo && !customEmoji.isLottie)) {
return ( return (
<img className={styles.media} src={mediaData} alt={customEmoji.emoji} /> <img className={styles.media} src={previewMediaData || mediaData} alt={customEmoji.emoji} />
); );
} }
@ -137,9 +151,9 @@ const CustomEmoji: FC<OwnProps> = ({
return ( return (
<AnimatedSticker <AnimatedSticker
size={size}
key={mediaData} key={mediaData}
className={styles.sticker} className={styles.sticker}
size={STICKER_SIZE}
tgsUrl={mediaData} tgsUrl={mediaData}
play={isIntersecting} play={isIntersecting}
color={customColor} color={customColor}
@ -160,6 +174,7 @@ const CustomEmoji: FC<OwnProps> = ({
'emoji', 'emoji',
hasCustomColor && 'custom-color', hasCustomColor && 'custom-color',
withGridFix && styles.withGridFix, withGridFix && styles.withGridFix,
...transitionClassNames,
)} )}
onClick={onClick} onClick={onClick}
> >

View File

@ -2,7 +2,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React, { import React, {
memo, useCallback, useEffect, useMemo, useRef, memo, useCallback, useEffect, useMemo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions, getGlobal } from '../../global';
import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types'; import type { ApiBotInlineMediaResult, ApiSticker } from '../../api/types';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
@ -11,6 +11,8 @@ import buildClassName from '../../util/buildClassName';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
import safePlay from '../../util/safePlay'; import safePlay from '../../util/safePlay';
import { IS_TOUCH_ENV, IS_WEBM_SUPPORTED } from '../../util/environment'; 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 type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
@ -76,7 +78,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const thumbDataUri = useThumbnail(sticker); const thumbDataUri = useThumbnail(sticker);
const previewBlobUrl = useMedia(`${localMediaHash}?size=m`, !isIntersecting, ApiMediaFormat.BlobUrl); const previewBlobUrl = useMedia(getStickerPreviewHash(sticker.id), !isIntersecting, ApiMediaFormat.BlobUrl);
const shouldPlay = isIntersecting && !noAnimate; const shouldPlay = isIntersecting && !noAnimate;
const lottieData = useMedia(sticker.isLottie && localMediaHash, !shouldPlay); const lottieData = useMedia(sticker.isLottie && localMediaHash, !shouldPlay);
@ -285,7 +287,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
tgsUrl={lottieData} tgsUrl={lottieData}
play play
size={size} size={size}
isLowPriority isLowPriority={!selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo)}
onLoad={markLoaded} onLoad={markLoaded}
/> />
)} )}

View File

@ -11,6 +11,7 @@ import buildClassName from '../../../util/buildClassName';
import renderText from './renderText'; import renderText from './renderText';
import { copyTextToClipboard } from '../../../util/clipboard'; import { copyTextToClipboard } from '../../../util/clipboard';
import { getTranslation } from '../../../util/langProvider'; import { getTranslation } from '../../../util/langProvider';
import { buildCustomEmojiHtmlFromEntity } from '../../middle/composer/helpers/customEmoji';
import MentionLink from '../../middle/message/MentionLink'; import MentionLink from '../../middle/message/MentionLink';
import SafeLink from '../SafeLink'; import SafeLink from '../SafeLink';
@ -480,6 +481,8 @@ function processEntityAsHtml(
class="spoiler" class="spoiler"
data-entity-type="${ApiMessageEntityTypes.Spoiler}" data-entity-type="${ApiMessageEntityTypes.Spoiler}"
>${renderedContent}</span>`; >${renderedContent}</span>`;
case ApiMessageEntityTypes.CustomEmoji:
return buildCustomEmojiHtmlFromEntity(rawEntityText, entity);
default: default:
return renderedContent; return renderedContent;
} }

View File

@ -34,6 +34,7 @@ import {
} from '../../../global/selectors'; } from '../../../global/selectors';
import { renderActionMessageText } from '../../common/helpers/renderActionMessageText'; import { renderActionMessageText } from '../../common/helpers/renderActionMessageText';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import { fastRaf } from '../../../util/schedulers'; import { fastRaf } from '../../../util/schedulers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { renderMessageSummary } from '../../common/helpers/renderMessageText'; import { renderMessageSummary } from '../../common/helpers/renderMessageText';
@ -249,7 +250,7 @@ const Chat: FC<OwnProps & StateProps> = ({
return ( return (
<p className="last-message" dir={lang.isRtl ? 'auto' : 'ltr'}> <p className="last-message" dir={lang.isRtl ? 'auto' : 'ltr'}>
<span className="draft">{lang('Draft')}</span> <span className="draft">{lang('Draft')}</span>
{renderText(draft.text)} {renderTextWithEntities(draft.text, draft.entities)}
</p> </p>
); );
} }

View File

@ -5,6 +5,7 @@ import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import type { ApiSticker, ApiStickerSet } from '../../../api/types'; import type { ApiSticker, ApiStickerSet } from '../../../api/types';
import type { ISettings } from '../../../types';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
@ -14,13 +15,16 @@ import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import StickerSetCard from '../../common/StickerSetCard'; import StickerSetCard from '../../common/StickerSetCard';
import Checkbox from '../../ui/Checkbox';
type OwnProps = { type OwnProps = {
isActive?: boolean; isActive?: boolean;
onReset: () => void; onReset: () => void;
}; };
type StateProps = { type StateProps = Pick<ISettings, (
'shouldSuggestCustomEmoji'
)> & {
customEmojiSetIds?: string[]; customEmojiSetIds?: string[];
stickerSetsById: Record<string, ApiStickerSet>; stickerSetsById: Record<string, ApiStickerSet>;
}; };
@ -29,9 +33,10 @@ const SettingsCustomEmoji: FC<OwnProps & StateProps> = ({
isActive, isActive,
customEmojiSetIds, customEmojiSetIds,
stickerSetsById, stickerSetsById,
shouldSuggestCustomEmoji,
onReset, onReset,
}) => { }) => {
const { openStickerSet } = getActions(); const { openStickerSet, setSettingOption } = getActions();
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -49,6 +54,10 @@ const SettingsCustomEmoji: FC<OwnProps & StateProps> = ({
}); });
}, [openStickerSet]); }, [openStickerSet]);
const handleSuggestCustomEmojiChange = useCallback((newValue: boolean) => {
setSettingOption({ shouldSuggestCustomEmoji: newValue });
}, [setSettingOption]);
const customEmojiSets = useMemo(() => ( const customEmojiSets = useMemo(() => (
customEmojiSetIds && Object.values(pick(stickerSetsById, customEmojiSetIds)) customEmojiSetIds && Object.values(pick(stickerSetsById, customEmojiSetIds))
), [customEmojiSetIds, stickerSetsById]); ), [customEmojiSetIds, stickerSetsById]);
@ -57,7 +66,12 @@ const SettingsCustomEmoji: FC<OwnProps & StateProps> = ({
<div className="settings-content custom-scroll"> <div className="settings-content custom-scroll">
{customEmojiSets && ( {customEmojiSets && (
<div className="settings-item"> <div className="settings-item">
<div ref={stickerSettingsRef}> <Checkbox
label={lang('SuggestAnimatedEmoji')}
checked={shouldSuggestCustomEmoji}
onCheck={handleSuggestCustomEmojiChange}
/>
<div className="mt-4" ref={stickerSettingsRef}>
{customEmojiSets.map((stickerSet: ApiStickerSet) => ( {customEmojiSets.map((stickerSet: ApiStickerSet) => (
<StickerSetCard <StickerSetCard
key={stickerSet.id} key={stickerSet.id}
@ -79,6 +93,9 @@ const SettingsCustomEmoji: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global) => { (global) => {
return { return {
...pick(global.settings.byKey, [
'shouldSuggestCustomEmoji',
]),
customEmojiSetIds: global.customEmojis.added.setIds, customEmojiSetIds: global.customEmojis.added.setIds,
stickerSetsById: global.stickers.setsById, stickerSetsById: global.stickers.setsById,
}; };

View File

@ -1,9 +1,10 @@
import React, { import React, {
memo, useCallback, useEffect, useRef, memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import type { ApiAttachment, ApiChatMember } from '../../../api/types'; import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types';
import { import {
CONTENT_TYPES_WITH_PREVIEW, CONTENT_TYPES_WITH_PREVIEW,
@ -23,6 +24,7 @@ import useLang from '../../../hooks/useLang';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import { useStateRef } from '../../../hooks/useStateRef'; import { useStateRef } from '../../../hooks/useStateRef';
import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
@ -31,6 +33,7 @@ import MessageInput from './MessageInput';
import MentionTooltip from './MentionTooltip'; import MentionTooltip from './MentionTooltip';
import EmojiTooltip from './EmojiTooltip.async'; import EmojiTooltip from './EmojiTooltip.async';
import CustomSendMenu from './CustomSendMenu.async'; import CustomSendMenu from './CustomSendMenu.async';
import CustomEmojiTooltip from './CustomEmojiTooltip.async';
import './AttachmentModal.scss'; import './AttachmentModal.scss';
@ -48,8 +51,9 @@ export type OwnProps = {
baseEmojiKeywords?: Record<string, string[]>; baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>; emojiKeywords?: Record<string, string[]>;
shouldSchedule?: boolean; shouldSchedule?: boolean;
shouldSuggestCustomEmoji?: boolean;
customEmojiForEmoji?: ApiSticker[];
captionLimit: number; captionLimit: number;
addRecentEmoji: AnyToVoidFunction;
onCaptionUpdate: (html: string) => void; onCaptionUpdate: (html: string) => void;
onSend: () => void; onSend: () => void;
onFileAppend: (files: File[], isQuick: boolean) => void; onFileAppend: (files: File[], isQuick: boolean) => void;
@ -75,7 +79,8 @@ const AttachmentModal: FC<OwnProps> = ({
baseEmojiKeywords, baseEmojiKeywords,
emojiKeywords, emojiKeywords,
shouldSchedule, shouldSchedule,
addRecentEmoji, shouldSuggestCustomEmoji,
customEmojiForEmoji,
onCaptionUpdate, onCaptionUpdate,
onSend, onSend,
onFileAppend, onFileAppend,
@ -83,6 +88,7 @@ const AttachmentModal: FC<OwnProps> = ({
onSendSilent, onSendSilent,
onSendScheduled, onSendScheduled,
}) => { }) => {
const { addRecentCustomEmoji, addRecentEmoji } = getActions();
const captionRef = useStateRef(caption); const captionRef = useStateRef(caption);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const mainButtonRef = useStateRef<HTMLButtonElement | null>(null); const mainButtonRef = useStateRef<HTMLButtonElement | null>(null);
@ -106,8 +112,22 @@ const AttachmentModal: FC<OwnProps> = ({
currentUserId, currentUserId,
); );
const { isCustomEmojiTooltipOpen, insertCustomEmoji } = useCustomEmojiTooltip(
Boolean(shouldSuggestCustomEmoji) && isOpen,
`#${EDITABLE_INPUT_MODAL_ID}`,
caption,
onCaptionUpdate,
customEmojiForEmoji,
!isReady,
);
const { const {
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, isEmojiTooltipOpen,
filteredEmojis,
filteredCustomEmojis,
insertEmoji,
insertCustomEmoji: insertCustomEmojiFromEmojiTooltip,
closeEmojiTooltip,
} = useEmojiTooltip( } = useEmojiTooltip(
isOpen, isOpen,
captionRef, captionRef,
@ -292,9 +312,18 @@ const AttachmentModal: FC<OwnProps> = ({
<EmojiTooltip <EmojiTooltip
isOpen={isEmojiTooltipOpen} isOpen={isEmojiTooltipOpen}
emojis={filteredEmojis} emojis={filteredEmojis}
customEmojis={filteredCustomEmojis}
onClose={closeEmojiTooltip} onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji} onEmojiSelect={insertEmoji}
onCustomEmojiSelect={insertCustomEmojiFromEmojiTooltip}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}
addRecentCustomEmoji={addRecentCustomEmoji}
/>
<CustomEmojiTooltip
chatId={chatId}
isOpen={isCustomEmojiTooltipOpen}
onCustomEmojiSelect={insertCustomEmoji}
addRecentCustomEmoji={addRecentCustomEmoji}
/> />
<MessageInput <MessageInput
id="caption-input-text" id="caption-input-text"

View File

@ -547,6 +547,11 @@
vertical-align: 0; vertical-align: 0;
pointer-events: none; pointer-events: none;
} }
.custom-emoji {
--custom-emoji-size: 1.25rem;
vertical-align: text-top;
}
} }
#caption-input-text { #caption-input-text {

View File

@ -76,6 +76,8 @@ import { isSelectionInsideInput } from './helpers/selection';
import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix'; import applyIosAutoCapitalizationFix from './helpers/applyIosAutoCapitalizationFix';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../global/selectors/limits';
import { buildCustomEmojiHtml } from './helpers/customEmoji';
import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
@ -95,6 +97,7 @@ import useMentionTooltip from './hooks/useMentionTooltip';
import useInlineBotTooltip from './hooks/useInlineBotTooltip'; import useInlineBotTooltip from './hooks/useInlineBotTooltip';
import useBotCommandTooltip from './hooks/useBotCommandTooltip'; import useBotCommandTooltip from './hooks/useBotCommandTooltip';
import useSchedule from '../../../hooks/useSchedule'; import useSchedule from '../../../hooks/useSchedule';
import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip';
import DeleteMessageModal from '../../common/DeleteMessageModal.async'; import DeleteMessageModal from '../../common/DeleteMessageModal.async';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
@ -107,6 +110,7 @@ import InlineBotTooltip from './InlineBotTooltip.async';
import MentionTooltip from './MentionTooltip.async'; import MentionTooltip from './MentionTooltip.async';
import CustomSendMenu from './CustomSendMenu.async'; import CustomSendMenu from './CustomSendMenu.async';
import StickerTooltip from './StickerTooltip.async'; import StickerTooltip from './StickerTooltip.async';
import CustomEmojiTooltip from './CustomEmojiTooltip.async';
import EmojiTooltip from './EmojiTooltip.async'; import EmojiTooltip from './EmojiTooltip.async';
import BotCommandTooltip from './BotCommandTooltip.async'; import BotCommandTooltip from './BotCommandTooltip.async';
import BotKeyboardMenu from './BotKeyboardMenu'; import BotKeyboardMenu from './BotKeyboardMenu';
@ -150,12 +154,14 @@ type StateProps =
shouldSchedule?: boolean; shouldSchedule?: boolean;
canScheduleUntilOnline?: boolean; canScheduleUntilOnline?: boolean;
stickersForEmoji?: ApiSticker[]; stickersForEmoji?: ApiSticker[];
customEmojiForEmoji?: ApiSticker[];
groupChatMembers?: ApiChatMember[]; groupChatMembers?: ApiChatMember[];
currentUserId?: string; currentUserId?: string;
recentEmojis: string[]; recentEmojis: string[];
lastSyncTime?: number; lastSyncTime?: number;
contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled']; contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled'];
shouldSuggestStickers?: boolean; shouldSuggestStickers?: boolean;
shouldSuggestCustomEmoji?: boolean;
baseEmojiKeywords?: Record<string, string[]>; baseEmojiKeywords?: Record<string, string[]>;
emojiKeywords?: Record<string, string[]>; emojiKeywords?: Record<string, string[]>;
topInlineBotIds?: string[]; topInlineBotIds?: string[];
@ -229,6 +235,7 @@ const Composer: FC<OwnProps & StateProps> = ({
botKeyboardPlaceholder, botKeyboardPlaceholder,
withScheduledButton, withScheduledButton,
stickersForEmoji, stickersForEmoji,
customEmojiForEmoji,
groupChatMembers, groupChatMembers,
topInlineBotIds, topInlineBotIds,
currentUserId, currentUserId,
@ -236,6 +243,7 @@ const Composer: FC<OwnProps & StateProps> = ({
lastSyncTime, lastSyncTime,
contentToBeScheduled, contentToBeScheduled,
shouldSuggestStickers, shouldSuggestStickers,
shouldSuggestCustomEmoji,
baseEmojiKeywords, baseEmojiKeywords,
emojiKeywords, emojiKeywords,
recentEmojis, recentEmojis,
@ -271,13 +279,15 @@ const Composer: FC<OwnProps & StateProps> = ({
resetOpenChatWithDraft, resetOpenChatWithDraft,
callAttachBot, callAttachBot,
openLimitReachedModal, openLimitReachedModal,
openPremiumModal,
addRecentCustomEmoji,
showNotification, showNotification,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const appendixRef = useRef<HTMLDivElement>(null); const appendixRef = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState<string>(''); const [html, setInnerHtml] = useState<string>('');
const htmlRef = useStateRef(html); const htmlRef = useStateRef(html);
const lastMessageSendTimeSeconds = useRef<number>(); const lastMessageSendTimeSeconds = useRef<number>();
const prevDropAreaState = usePrevious(dropAreaState); const prevDropAreaState = usePrevious(dropAreaState);
@ -289,6 +299,15 @@ const Composer: FC<OwnProps & StateProps> = ({
const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag(); const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag();
const sendMessageAction = useSendMessageAction(chatId, threadId); const sendMessageAction = useSendMessageAction(chatId, threadId);
const setHtml = useCallback((newHtml: string) => {
setInnerHtml(newHtml);
requestAnimationFrame(() => {
processMessageInputForCustomEmoji();
});
}, []);
const customEmojiNotificationNumber = useRef(0);
const handleScheduleCancel = useCallback(() => { const handleScheduleCancel = useCallback(() => {
cancelForceShowSymbolMenu(); cancelForceShowSymbolMenu();
}, [cancelForceShowSymbolMenu]); }, [cancelForceShowSymbolMenu]);
@ -441,8 +460,21 @@ const Composer: FC<OwnProps & StateProps> = ({
stickersForEmoji, stickersForEmoji,
!isReady, !isReady,
); );
const { isCustomEmojiTooltipOpen, closeCustomEmojiTooltip, insertCustomEmoji } = useCustomEmojiTooltip(
Boolean(shouldSuggestCustomEmoji && !attachments.length),
EDITABLE_INPUT_CSS_SELECTOR,
html,
setHtml,
customEmojiForEmoji,
!isReady,
);
const { const {
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, isEmojiTooltipOpen,
closeEmojiTooltip,
filteredEmojis,
filteredCustomEmojis,
insertEmoji,
insertCustomEmoji: insertCustomEmojiFromEmojiTooltip,
} = useEmojiTooltip( } = useEmojiTooltip(
Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), Boolean(shouldSuggestStickers && canSendStickers && !attachments.length),
htmlRef, htmlRef,
@ -478,7 +510,7 @@ const Composer: FC<OwnProps & StateProps> = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
focusEditableElement(messageInput); focusEditableElement(messageInput);
}); });
}, [htmlRef]); }, [htmlRef, setHtml]);
const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => { const insertTextAndUpdateCursor = useCallback((text: string, inputId: string = EDITABLE_INPUT_ID) => {
const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html']) const newHtml = renderText(text, ['escape_html', 'emoji_html', 'br_html'])
@ -487,6 +519,10 @@ const Composer: FC<OwnProps & StateProps> = ({
insertHtmlAndUpdateCursor(newHtml, inputId); insertHtmlAndUpdateCursor(newHtml, inputId);
}, [insertHtmlAndUpdateCursor]); }, [insertHtmlAndUpdateCursor]);
const insertCustomEmojiAndUpdateCursor = useCallback((emoji: ApiSticker, inputId: string = EDITABLE_INPUT_ID) => {
insertHtmlAndUpdateCursor(buildCustomEmojiHtml(emoji), inputId);
}, [insertHtmlAndUpdateCursor]);
const removeSymbol = useCallback(() => { const removeSymbol = useCallback(() => {
const selection = window.getSelection()!; const selection = window.getSelection()!;
@ -499,7 +535,7 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!)); setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!));
}, [htmlRef]); }, [htmlRef, setHtml]);
const resetComposer = useCallback((shouldPreserveInput = false) => { const resetComposer = useCallback((shouldPreserveInput = false) => {
if (!shouldPreserveInput) { if (!shouldPreserveInput) {
@ -507,6 +543,7 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
setAttachments(MEMO_EMPTY_ARRAY); setAttachments(MEMO_EMPTY_ARRAY);
closeStickerTooltip(); closeStickerTooltip();
closeCustomEmojiTooltip();
closeMentionTooltip(); closeMentionTooltip();
closeEmojiTooltip(); closeEmojiTooltip();
@ -516,7 +553,7 @@ const Composer: FC<OwnProps & StateProps> = ({
} else { } else {
closeSymbolMenu(); closeSymbolMenu();
} }
}, [closeStickerTooltip, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu]); }, [closeStickerTooltip, closeCustomEmojiTooltip, closeMentionTooltip, closeEmojiTooltip, closeSymbolMenu, setHtml]);
// Handle chat change (ref is used to avoid redundant effect calls) // Handle chat change (ref is used to avoid redundant effect calls)
const stopRecordingVoiceRef = useRef<typeof stopRecordingVoice>(); const stopRecordingVoiceRef = useRef<typeof stopRecordingVoice>();
@ -734,7 +771,33 @@ const Composer: FC<OwnProps & StateProps> = ({
focusEditableElement(messageInput, true); 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(( const handleStickerSelect = useCallback((
sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false, sticker: ApiSticker, isSilent?: boolean, isScheduleRequested?: boolean, shouldPreserveInput = false,
@ -1029,13 +1092,14 @@ const Composer: FC<OwnProps & StateProps> = ({
onCaptionUpdate={setHtml} onCaptionUpdate={setHtml}
baseEmojiKeywords={baseEmojiKeywords} baseEmojiKeywords={baseEmojiKeywords}
emojiKeywords={emojiKeywords} emojiKeywords={emojiKeywords}
addRecentEmoji={addRecentEmoji}
shouldSchedule={shouldSchedule} shouldSchedule={shouldSchedule}
onSendSilent={handleSendSilent} onSendSilent={handleSendSilent}
onSend={handleSend} onSend={handleSend}
onSendScheduled={handleSendScheduled} onSendScheduled={handleSendScheduled}
onFileAppend={handleAppendFiles} onFileAppend={handleAppendFiles}
onClear={handleClearAttachment} onClear={handleClearAttachment}
shouldSuggestCustomEmoji={shouldSuggestCustomEmoji}
customEmojiForEmoji={customEmojiForEmoji}
/> />
<PollModal <PollModal
isOpen={pollModal.isOpen} isOpen={pollModal.isOpen}
@ -1237,12 +1301,21 @@ const Composer: FC<OwnProps & StateProps> = ({
isOpen={isStickerTooltipOpen} isOpen={isStickerTooltipOpen}
onStickerSelect={handleStickerSelect} onStickerSelect={handleStickerSelect}
/> />
<CustomEmojiTooltip
chatId={chatId}
isOpen={isCustomEmojiTooltipOpen}
onCustomEmojiSelect={insertCustomEmoji}
addRecentCustomEmoji={addRecentCustomEmoji}
/>
<EmojiTooltip <EmojiTooltip
isOpen={isEmojiTooltipOpen} isOpen={isEmojiTooltipOpen}
emojis={filteredEmojis} emojis={filteredEmojis}
customEmojis={filteredCustomEmojis}
onClose={closeEmojiTooltip} onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji} onEmojiSelect={insertEmoji}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}
onCustomEmojiSelect={insertCustomEmojiFromEmojiTooltip}
addRecentCustomEmoji={addRecentCustomEmoji}
/> />
<SymbolMenu <SymbolMenu
chatId={chatId} chatId={chatId}
@ -1254,10 +1327,12 @@ const Composer: FC<OwnProps & StateProps> = ({
onClose={closeSymbolMenu} onClose={closeSymbolMenu}
onEmojiSelect={insertTextAndUpdateCursor} onEmojiSelect={insertTextAndUpdateCursor}
onStickerSelect={handleStickerSelect} onStickerSelect={handleStickerSelect}
onCustomEmojiSelect={handleCustomEmojiSelect}
onGifSelect={handleGifSelect} onGifSelect={handleGifSelect}
onRemoveSymbol={removeSymbol} onRemoveSymbol={removeSymbol}
onSearchOpen={handleSearchOpen} onSearchOpen={handleSearchOpen}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}
addRecentCustomEmoji={addRecentCustomEmoji}
/> />
</div> </div>
</div> </div>
@ -1313,7 +1388,7 @@ export default memo(withGlobal<OwnProps>(
const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const messageWithActualBotKeyboard = isChatWithBot && selectNewestMessageWithBotKeyboardButtons(global, chatId); const messageWithActualBotKeyboard = isChatWithBot && selectNewestMessageWithBotKeyboardButtons(global, chatId);
const scheduledIds = selectScheduledIds(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 baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG];
const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined;
const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined; const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined;
@ -1360,12 +1435,14 @@ export default memo(withGlobal<OwnProps>(
isForwarding: chatId === global.forwardMessages.toChatId, isForwarding: chatId === global.forwardMessages.toChatId,
pollModal: global.pollModal, pollModal: global.pollModal,
stickersForEmoji: global.stickers.forEmoji.stickers, stickersForEmoji: global.stickers.forEmoji.stickers,
customEmojiForEmoji: global.customEmojis.forEmoji.stickers,
groupChatMembers: chat?.fullInfo?.members, groupChatMembers: chat?.fullInfo?.members,
topInlineBotIds: global.topInlineBots?.userIds, topInlineBotIds: global.topInlineBots?.userIds,
currentUserId, currentUserId,
lastSyncTime: global.lastSyncTime, lastSyncTime: global.lastSyncTime,
contentToBeScheduled: global.messages.contentToBeScheduled, contentToBeScheduled: global.messages.contentToBeScheduled,
shouldSuggestStickers, shouldSuggestStickers,
shouldSuggestCustomEmoji,
recentEmojis: global.recentEmojis, recentEmojis: global.recentEmojis,
baseEmojiKeywords: baseEmojiKeywords?.keywords, baseEmojiKeywords: baseEmojiKeywords?.keywords,
emojiKeywords: emojiKeywords?.keywords, emojiKeywords: emojiKeywords?.keywords,

View File

@ -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<OwnProps> = ({
emoji, focus, onClick,
}) => {
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
// Preventing safari from losing focus on Composer MessageInput
e.preventDefault();
onClick?.(emoji);
}, [emoji, onClick]);
const className = buildClassName(
'EmojiButton',
focus && 'focus',
);
return (
<div
className={className}
onMouseDown={handleClick}
title={emoji.emoji}
>
<CustomEmoji documentId={emoji.id} size={CUSTOM_EMOJI_SIZE} withPreview />
</div>
);
};
export default memo(CustomEmojiButton);

View File

@ -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<string, ApiStickerSet>;
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<OwnProps & StateProps> = ({
className,
loadAndPlay,
addedCustomEmojiIds,
recentCustomEmoji,
stickerSetsById,
featuredCustomEmojiIds,
shouldPlay,
isSavedMessages,
isCurrentUserPremium,
onCustomEmojiSelect,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const headerRef = useRef<HTMLDivElement>(null);
const [activeSetIndex, setActiveSetIndex] = useState<number>(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 (
<Button
key={stickerSet.id}
className={buttonClassName}
ariaLabel={stickerSet.title}
round
faded={stickerSet.id === RECENT_SYMBOL_SET_ID || stickerSet.id === FAVORITE_SYMBOL_SET_ID}
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => selectStickerSet(index)}
>
{stickerSet.id === RECENT_SYMBOL_SET_ID ? (
<i className="icon-recent" />
) : stickerSet.isLottie ? (
<StickerSetCoverAnimated
stickerSet={stickerSet as ApiStickerSet}
observeIntersection={observeIntersectionForCovers}
/>
) : (
<StickerSetCover
stickerSet={stickerSet as ApiStickerSet}
observeIntersection={observeIntersectionForCovers}
/>
)}
</Button>
);
} else {
return (
<StickerButton
key={stickerSet.id}
sticker={firstSticker}
size={STICKER_SIZE_PICKER_HEADER}
title={stickerSet.title}
className={buttonClassName}
observeIntersection={observeIntersectionForCovers}
onClick={selectStickerSet}
clickArg={index}
noContextMenu
isCurrentUserPremium
/>
);
}
}
const fullClassName = buildClassName('StickerPicker', 'CustomEmojiPicker', className);
if (!areAddedLoaded || !canRenderContents || noPopulatedSets) {
return (
<div className={fullClassName}>
{noPopulatedSets ? (
<div className="picker-disabled">{lang('NoStickers')}</div>
) : (
<Loading />
)}
</div>
);
}
return (
<div className={fullClassName}>
<div
ref={headerRef}
className="StickerPicker-header no-selection no-scrollbar"
>
{allSets.map(renderCover)}
</div>
<div
ref={containerRef}
className={buildClassName('StickerPicker-main no-selection', IS_TOUCH_ENV ? 'no-scrollbar' : 'custom-scroll')}
>
{allSets.map((stickerSet, i) => (
<StickerSet
key={stickerSet.id}
stickerSet={stickerSet}
loadAndPlay={Boolean(shouldPlay && loadAndPlay)}
index={i}
observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
onStickerSelect={handleEmojiSelect}
isSavedMessages={isSavedMessages}
isCustomEmojiPicker
isCurrentUserPremium={isCurrentUserPremium}
/>
))}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(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));

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const CustomEmojiTooltip = useModuleLoader(Bundles.Extra, 'CustomEmojiTooltip', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return CustomEmojiTooltip ? <CustomEmojiTooltip {...props} /> : undefined;
};
export default memo(CustomEmojiTooltipAsync);

View File

@ -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;
}
}

View File

@ -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<OwnProps & StateProps> = ({
isOpen,
customEmoji,
isSavedMessages,
isCurrentUserPremium,
onCustomEmojiSelect,
addRecentCustomEmoji,
}) => {
const { clearCustomEmojiForEmoji } = getActions();
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
className={className}
>
{shouldRender && displayedStickers ? (
displayedStickers.map((sticker) => (
<StickerButton
key={sticker.id}
sticker={sticker}
className={styles.emojiButton}
size={EMOJI_SIZE_PICKER}
observeIntersection={observeIntersection}
onClick={handleCustomEmojiSelect}
clickArg={sticker}
isSavedMessages={isSavedMessages}
canViewSet
isCurrentUserPremium={isCurrentUserPremium}
/>
))
) : shouldRender ? (
<Loading />
) : undefined}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const { stickers: customEmoji } = global.customEmojis.forEmoji;
const isSavedMessages = selectIsChatWithSelf(global, chatId);
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return { customEmoji, isSavedMessages, isCurrentUserPremium };
},
)(CustomEmojiTooltip));

View File

@ -30,4 +30,8 @@
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
} }
& > .custom-emoji {
--custom-emoji-size: 2rem;
}
} }

View File

@ -1,6 +1,7 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback } 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 { IS_EMOJI_SUPPORTED } from '../../../util/environment';
import { handleEmojiLoad, LOADED_EMOJIS } from '../../../util/emoji'; import { handleEmojiLoad, LOADED_EMOJIS } from '../../../util/emoji';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -13,7 +14,9 @@ type OwnProps = {
onClick: (emoji: string, name: string) => void; onClick: (emoji: string, name: string) => void;
}; };
const EmojiButton: FC<OwnProps> = ({ emoji, focus, onClick }) => { const EmojiButton: FC<OwnProps> = ({
emoji, focus, onClick,
}) => {
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => { const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
// Preventing safari from losing focus on Composer MessageInput // Preventing safari from losing focus on Composer MessageInput
e.preventDefault(); e.preventDefault();

View File

@ -1,8 +1,10 @@
import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, useRef, memo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
import type { FC } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import findInViewport from '../../../util/findInViewport'; import findInViewport from '../../../util/findInViewport';
import isFullyVisible from '../../../util/isFullyVisible'; import isFullyVisible from '../../../util/isFullyVisible';
@ -15,6 +17,7 @@ import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
import Loading from '../../ui/Loading'; import Loading from '../../ui/Loading';
import EmojiButton from './EmojiButton'; import EmojiButton from './EmojiButton';
import CustomEmojiButton from './CustomEmojiButton';
import './EmojiTooltip.scss'; import './EmojiTooltip.scss';
@ -52,23 +55,31 @@ function setItemVisible(index: number, containerRef: Record<string, any>) {
export type OwnProps = { export type OwnProps = {
isOpen: boolean; isOpen: boolean;
onEmojiSelect: (text: string) => void;
onClose: NoneToVoidFunction;
addRecentEmoji: AnyToVoidFunction;
emojis: Emoji[]; 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<OwnProps> = ({ const EmojiTooltip: FC<OwnProps> = ({
isOpen, isOpen,
emojis, emojis,
customEmojis,
onClose, onClose,
onEmojiSelect, onEmojiSelect,
onCustomEmojiSelect,
addRecentEmoji, addRecentEmoji,
addRecentCustomEmoji,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); 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); useHorizontalScroll(containerRef.current);
@ -77,16 +88,34 @@ const EmojiTooltip: FC<OwnProps> = ({
addRecentEmoji({ emoji: emoji.id }); addRecentEmoji({ emoji: emoji.id });
}, [addRecentEmoji, onEmojiSelect]); }, [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) => { const handleClick = useCallback((native: string, id: string) => {
onEmojiSelect(native); onEmojiSelect(native);
addRecentEmoji({ emoji: id }); addRecentEmoji({ emoji: id });
}, [addRecentEmoji, onEmojiSelect]); }, [addRecentEmoji, onEmojiSelect]);
const handleCustomEmojiClick = useCallback((emoji: ApiSticker) => {
onCustomEmojiSelect(emoji);
addRecentCustomEmoji({ documentId: emoji.id });
}, [addRecentCustomEmoji, onCustomEmojiSelect]);
const selectedIndex = useKeyboardNavigation({ const selectedIndex = useKeyboardNavigation({
isActive: isOpen, isActive: isOpen,
isHorizontal: true, isHorizontal: true,
items: emojis, items: listEmojis,
onSelect: handleSelectEmoji, onSelect: handleSelect,
onClose, onClose,
}); });
@ -106,12 +135,21 @@ const EmojiTooltip: FC<OwnProps> = ({
> >
{shouldRender && listEmojis ? ( {shouldRender && listEmojis ? (
listEmojis.map((emoji, index) => ( listEmojis.map((emoji, index) => (
<EmojiButton 'native' in emoji ? (
key={emoji.id} <EmojiButton
emoji={emoji} key={emoji.id}
focus={selectedIndex === index} emoji={emoji}
onClick={handleClick} focus={selectedIndex === index}
/> onClick={handleClick}
/>
) : (
<CustomEmojiButton
key={emoji.id}
emoji={emoji}
focus={selectedIndex === index}
onClick={handleCustomEmojiClick}
/>
)
)) ))
) : shouldRender ? ( ) : shouldRender ? (
<Loading /> <Loading />

View File

@ -297,6 +297,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
&& (!textContent || !textContent.length) && (!textContent || !textContent.length)
// When emojis are not supported, innerHTML contains an emoji img tag that doesn't exist in the textContext // 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')) && !(!IS_EMOJI_SUPPORTED && innerHTML.includes('emoji-small'))
&& !(innerHTML.includes('custom-emoji'))
) { ) {
const selection = window.getSelection()!; const selection = window.getSelection()!;
if (selection) { if (selection) {

View File

@ -75,6 +75,10 @@
left: 0; left: 0;
} }
} }
&.activated {
background-color: var(--color-background-selected);
}
} }
} }

View File

@ -15,6 +15,7 @@ import {
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import windowSize from '../../../util/windowSize'; import windowSize from '../../../util/windowSize';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { selectIsSetPremium } from '../../../global/selectors';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -31,12 +32,13 @@ type OwnProps = {
shouldRender: boolean; shouldRender: boolean;
favoriteStickers?: ApiSticker[]; favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean; isSavedMessages?: boolean;
isCurrentUserPremium?: boolean;
isCustomEmojiPicker?: boolean;
observeIntersection: ObserveFn; observeIntersection: ObserveFn;
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onStickerUnfave?: (sticker: ApiSticker) => void; onStickerUnfave?: (sticker: ApiSticker) => void;
onStickerFave?: (sticker: ApiSticker) => void; onStickerFave?: (sticker: ApiSticker) => void;
onStickerRemoveRecent?: (sticker: ApiSticker) => void; onStickerRemoveRecent?: (sticker: ApiSticker) => void;
isCurrentUserPremium?: boolean;
}; };
const STICKERS_PER_ROW_ON_DESKTOP = 5; const STICKERS_PER_ROW_ON_DESKTOP = 5;
@ -52,14 +54,20 @@ const StickerSet: FC<OwnProps> = ({
shouldRender, shouldRender,
favoriteStickers, favoriteStickers,
isSavedMessages, isSavedMessages,
isCurrentUserPremium,
isCustomEmojiPicker,
observeIntersection, observeIntersection,
onStickerSelect, onStickerSelect,
onStickerUnfave, onStickerUnfave,
onStickerFave, onStickerFave,
onStickerRemoveRecent, onStickerRemoveRecent,
isCurrentUserPremium,
}) => { }) => {
const { clearRecentStickers } = getActions(); const {
clearRecentStickers,
clearRecentCustomEmoji,
openPremiumModal,
toggleStickerSet,
} = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
@ -70,14 +78,32 @@ const StickerSet: FC<OwnProps> = ({
const transitionClassNames = useMediaTransition(shouldRender); const transitionClassNames = useMediaTransition(shouldRender);
const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID;
const isEmoji = stickerSet.isEmoji; const isEmoji = stickerSet.isEmoji;
const isPremiumSet = !isRecent && selectIsSetPremium(stickerSet);
const handleClearRecent = useCallback(() => { const handleClearRecent = useCallback(() => {
clearRecentStickers(); if (isEmoji) {
clearRecentCustomEmoji();
} else {
clearRecentStickers();
}
closeConfirmModal(); 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); && stickerSet.stickers?.some((l) => !l.isFree);
const itemSize = isEmoji ? EMOJI_SIZE_PICKER : STICKER_SIZE_PICKER; const itemSize = isEmoji ? EMOJI_SIZE_PICKER : STICKER_SIZE_PICKER;
const itemsPerRow = isEmoji ? EMOJI_PER_ROW_ON_DESKTOP : STICKERS_PER_ROW_ON_DESKTOP; const itemsPerRow = isEmoji ? EMOJI_PER_ROW_ON_DESKTOP : STICKERS_PER_ROW_ON_DESKTOP;
@ -97,13 +123,11 @@ const StickerSet: FC<OwnProps> = ({
favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined
), [favoriteStickers]); ), [favoriteStickers]);
const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID;
return ( return (
<div <div
ref={ref} ref={ref}
key={stickerSet.id} key={stickerSet.id}
id={`sticker-set-${index}`} id={`${isCustomEmojiPicker ? 'custom-emoji-set' : 'sticker-set'}-${index}`}
className={ className={
buildClassName('symbol-set', isLocked && 'symbol-set-locked') buildClassName('symbol-set', isLocked && 'symbol-set-locked')
} }
@ -116,6 +140,18 @@ const StickerSet: FC<OwnProps> = ({
{isRecent && ( {isRecent && (
<i className="symbol-set-remove icon-close" onClick={openConfirmModal} /> <i className="symbol-set-remove icon-close" onClick={openConfirmModal} />
)} )}
{!isRecent && isEmoji && !stickerSet.installedDate && (
<Button
className="symbol-set-add-button"
withPremiumGradient={isPremiumSet && !isCurrentUserPremium}
onClick={handleAddClick}
pill
size="tiny"
fluid
>
{isPremiumSet && isLocked ? lang('Unlock') : lang('Add')}
</Button>
)}
</div> </div>
<div <div
className={buildClassName('symbol-set-container', transitionClassNames)} className={buildClassName('symbol-set-container', transitionClassNames)}
@ -141,7 +177,7 @@ const StickerSet: FC<OwnProps> = ({
isCurrentUserPremium={isCurrentUserPremium} isCurrentUserPremium={isCurrentUserPremium}
/> />
))} ))}
{!isExpanded && stickerSet.count > itemsBeforeCutout - 1 && ( {!isExpanded && stickerSet.count > itemsBeforeCutout && (
<Button className="StickerButton custom-emoji set-expand" round color="translucent" onClick={expand}> <Button className="StickerButton custom-emoji set-expand" round color="translucent" onClick={expand}>
+{stickerSet.count - itemsBeforeCutout + 1} +{stickerSet.count - itemsBeforeCutout + 1}
</Button> </Button>

View File

@ -4,11 +4,12 @@ import React, { memo, useMemo, useRef } from '../../../lib/teact/teact';
import type { ApiStickerSet } from '../../../api/types'; import type { ApiStickerSet } from '../../../api/types';
import { STICKER_SIZE_PICKER_HEADER } from '../../../config'; import { STICKER_SIZE_PICKER_HEADER } from '../../../config';
import { getFirstLetters } from '../../../util/textFormat';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useMediaTransition from '../../../hooks/useMediaTransition'; import useMediaTransition from '../../../hooks/useMediaTransition';
import { getFirstLetters } from '../../../util/textFormat';
import AnimatedSticker from '../../common/AnimatedSticker'; import AnimatedSticker from '../../common/AnimatedSticker';
@ -46,6 +47,7 @@ const StickerSetCoverAnimated: FC<OwnProps> = ({
size={size} size={size}
tgsUrl={lottieData} tgsUrl={lottieData}
className={transitionClassNames} className={transitionClassNames}
play={isIntersecting}
/> />
)} )}
</div> </div>

View File

@ -181,8 +181,8 @@
&-header { &-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
color: rgba(var(--color-text-secondary-rgb), 0.75); color: rgba(var(--color-text-secondary-rgb), 0.75);
align-self: center;
} }
&-name { &-name {
@ -196,7 +196,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: center; text-align: center;
unicode-bidi: plaintext; unicode-bidi: plaintext;
flex-grow: 1;
z-index: 1; z-index: 1;
background-color: var(--color-background); background-color: var(--color-background);
} }

View File

@ -20,6 +20,7 @@ import Button from '../../ui/Button';
import Menu from '../../ui/Menu'; import Menu from '../../ui/Menu';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import EmojiPicker from './EmojiPicker'; import EmojiPicker from './EmojiPicker';
import CustomEmojiPicker from './CustomEmojiPicker';
import StickerPicker from './StickerPicker'; import StickerPicker from './StickerPicker';
import GifPicker from './GifPicker'; import GifPicker from './GifPicker';
import SymbolMenuFooter, { SYMBOL_MENU_TAB_TITLES, SymbolMenuTabs } from './SymbolMenuFooter'; import SymbolMenuFooter, { SYMBOL_MENU_TAB_TITLES, SymbolMenuTabs } from './SymbolMenuFooter';
@ -38,6 +39,7 @@ export type OwnProps = {
onLoad: () => void; onLoad: () => void;
onClose: () => void; onClose: () => void;
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
onCustomEmojiSelect: (emoji: ApiSticker) => void;
onStickerSelect: ( onStickerSelect: (
sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldPreserveInput?: boolean sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldPreserveInput?: boolean
) => void; ) => void;
@ -45,11 +47,13 @@ export type OwnProps = {
onRemoveSymbol: () => void; onRemoveSymbol: () => void;
onSearchOpen: (type: 'stickers' | 'gifs') => void; onSearchOpen: (type: 'stickers' | 'gifs') => void;
addRecentEmoji: GlobalActions['addRecentEmoji']; addRecentEmoji: GlobalActions['addRecentEmoji'];
addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji'];
}; };
type StateProps = { type StateProps = {
isLeftColumnShown: boolean; isLeftColumnShown: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
lastSyncTime?: number;
}; };
let isActivated = false; let isActivated = false;
@ -62,18 +66,22 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
canSendGifs, canSendGifs,
isLeftColumnShown, isLeftColumnShown,
isCurrentUserPremium, isCurrentUserPremium,
lastSyncTime,
onLoad, onLoad,
onClose, onClose,
onEmojiSelect, onEmojiSelect,
onCustomEmojiSelect,
onStickerSelect, onStickerSelect,
onGifSelect, onGifSelect,
onRemoveSymbol, onRemoveSymbol,
onSearchOpen, onSearchOpen,
addRecentEmoji, addRecentEmoji,
addRecentCustomEmoji,
}) => { }) => {
const { loadPremiumSetStickers } = getActions(); const { loadPremiumSetStickers, loadFeaturedEmojiStickers } = getActions();
const [activeTab, setActiveTab] = useState<number>(0); const [activeTab, setActiveTab] = useState<number>(0);
const [recentEmojis, setRecentEmojis] = useState<string[]>([]); const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const [recentCustomEmojis, setRecentCustomEmojis] = useState<string[]>([]);
const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose, undefined, IS_SINGLE_COLUMN_LAYOUT); const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose, undefined, IS_SINGLE_COLUMN_LAYOUT);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen, onClose, false, false); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, onClose, false, false);
@ -87,10 +95,12 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
}, [onLoad]); }, [onLoad]);
useEffect(() => { useEffect(() => {
if (!lastSyncTime) return;
if (isCurrentUserPremium) { if (isCurrentUserPremium) {
loadPremiumSetStickers(); loadPremiumSetStickers();
} }
}, [isCurrentUserPremium, loadPremiumSetStickers]); loadFeaturedEmojiStickers();
}, [isCurrentUserPremium, lastSyncTime, loadFeaturedEmojiStickers, loadPremiumSetStickers]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!IS_SINGLE_COLUMN_LAYOUT) { if (!IS_SINGLE_COLUMN_LAYOUT) {
@ -134,6 +144,28 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onEmojiSelect(emoji); onEmojiSelect(emoji);
}, [onEmojiSelect]); }, [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') => { const handleSearch = useCallback((type: 'stickers' | 'gifs') => {
onClose(); onClose();
onSearchOpen(type); onSearchOpen(type);
@ -154,6 +186,15 @@ const SymbolMenu: FC<OwnProps & StateProps> = ({
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
/> />
); );
case SymbolMenuTabs.CustomEmoji:
return (
<CustomEmojiPicker
className="picker-tab"
loadAndPlay={isOpen && (isActive || isFrom)}
onCustomEmojiSelect={handleCustomEmojiSelect}
chatId={chatId}
/>
);
case SymbolMenuTabs.Stickers: case SymbolMenuTabs.Stickers:
return ( return (
<StickerPicker <StickerPicker
@ -257,6 +298,7 @@ export default memo(withGlobal<OwnProps>(
return { return {
isLeftColumnShown: global.isLeftColumnShown, isLeftColumnShown: global.isLeftColumnShown,
isCurrentUserPremium: selectIsCurrentUserPremium(global), isCurrentUserPremium: selectIsCurrentUserPremium(global),
lastSyncTime: global.lastSyncTime,
}; };
}, },
)(SymbolMenu)); )(SymbolMenu));

View File

@ -14,18 +14,21 @@ type OwnProps = {
export enum SymbolMenuTabs { export enum SymbolMenuTabs {
'Emoji', 'Emoji',
'CustomEmoji',
'Stickers', 'Stickers',
'GIFs', 'GIFs',
} }
export const SYMBOL_MENU_TAB_TITLES: Record<SymbolMenuTabs, string> = { export const SYMBOL_MENU_TAB_TITLES: Record<SymbolMenuTabs, string> = {
[SymbolMenuTabs.Emoji]: 'Emoji', [SymbolMenuTabs.Emoji]: 'Emoji',
[SymbolMenuTabs.CustomEmoji]: 'StickersList.EmojiItem',
[SymbolMenuTabs.Stickers]: 'AccDescrStickers', [SymbolMenuTabs.Stickers]: 'AccDescrStickers',
[SymbolMenuTabs.GIFs]: 'GifsTab', [SymbolMenuTabs.GIFs]: 'GifsTab',
}; };
const SYMBOL_MENU_TAB_ICONS = { const SYMBOL_MENU_TAB_ICONS = {
[SymbolMenuTabs.Emoji]: 'icon-smile', [SymbolMenuTabs.Emoji]: 'icon-smile',
[SymbolMenuTabs.CustomEmoji]: 'icon-favorite',
[SymbolMenuTabs.Stickers]: 'icon-stickers', [SymbolMenuTabs.Stickers]: 'icon-stickers',
[SymbolMenuTabs.GIFs]: 'icon-gifs', [SymbolMenuTabs.GIFs]: 'icon-gifs',
}; };
@ -61,7 +64,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
return ( return (
<div className="SymbolMenu-footer" onClick={stopPropagation} dir={lang.isRtl ? 'rtl' : undefined}> <div className="SymbolMenu-footer" onClick={stopPropagation} dir={lang.isRtl ? 'rtl' : undefined}>
{activeTab !== SymbolMenuTabs.Emoji && ( {activeTab !== SymbolMenuTabs.Emoji && activeTab !== SymbolMenuTabs.CustomEmoji && (
<Button <Button
className="symbol-search-button" className="symbol-search-button"
ariaLabel={activeTab === SymbolMenuTabs.Stickers ? 'Search Stickers' : 'Search GIFs'} ariaLabel={activeTab === SymbolMenuTabs.Stickers ? 'Search Stickers' : 'Search GIFs'}
@ -75,6 +78,7 @@ const SymbolMenuFooter: FC<OwnProps> = ({
)} )}
{renderTabButton(SymbolMenuTabs.Emoji)} {renderTabButton(SymbolMenuTabs.Emoji)}
{renderTabButton(SymbolMenuTabs.CustomEmoji)}
{renderTabButton(SymbolMenuTabs.Stickers)} {renderTabButton(SymbolMenuTabs.Stickers)}
{renderTabButton(SymbolMenuTabs.GIFs)} {renderTabButton(SymbolMenuTabs.GIFs)}

View File

@ -11,6 +11,8 @@ import buildClassName from '../../../util/buildClassName';
import { ensureProtocol } from '../../../util/ensureProtocol'; import { ensureProtocol } from '../../../util/ensureProtocol';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import getKeyFromEvent from '../../../util/getKeyFromEvent'; import getKeyFromEvent from '../../../util/getKeyFromEvent';
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop'; import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -129,11 +131,16 @@ const TextFormatter: FC<OwnProps> = ({
} }
}, [setSelectedRange]); }, [setSelectedRange]);
const getSelectedText = useCallback(() => { const getSelectedText = useCallback((shouldDropCustomEmoji?: boolean) => {
if (!selectedRange) { if (!selectedRange) {
return undefined; return undefined;
} }
fragmentEl.replaceChildren(selectedRange.cloneContents()); fragmentEl.replaceChildren(selectedRange.cloneContents());
if (shouldDropCustomEmoji) {
fragmentEl.querySelectorAll(INPUT_CUSTOM_EMOJI_SELECTOR).forEach((el) => {
el.replaceWith(el.getAttribute('alt')!);
});
}
return fragmentEl.innerHTML; return fragmentEl.innerHTML;
}, [selectedRange]); }, [selectedRange]);
@ -304,7 +311,7 @@ const TextFormatter: FC<OwnProps> = ({
return; return;
} }
const text = getSelectedText(); const text = getSelectedText(true);
document.execCommand('insertHTML', false, `<code class="text-entity-code" dir="auto">${text}</code>`); document.execCommand('insertHTML', false, `<code class="text-entity-code" dir="auto">${text}</code>`);
onClose(); onClose();
}, [ }, [
@ -327,7 +334,7 @@ const TextFormatter: FC<OwnProps> = ({
return; return;
} }
const text = getSelectedText(); const text = getSelectedText(true);
restoreSelection(); restoreSelection();
document.execCommand( document.execCommand(
'insertHTML', 'insertHTML',

View File

@ -0,0 +1,26 @@
import type { ApiMessageEntityCustomEmoji, ApiSticker } from '../../../../api/types';
import { getCustomEmojiPreviewMediaData } from '../../../../util/customEmojiManager';
export const INPUT_CUSTOM_EMOJI_SELECTOR = 'img[data-document-id]';
export function buildCustomEmojiHtml(emoji: ApiSticker) {
const mediaData = getCustomEmojiPreviewMediaData(emoji.id);
const src = mediaData && `src="${mediaData}"`;
return `<img
class="custom-emoji emoji emoji-small"
draggable="false"
alt="${emoji.emoji}"
data-document-id="${emoji.id}"
${src} />`;
}
export function buildCustomEmojiHtmlFromEntity(rawText: string, entity: ApiMessageEntityCustomEmoji) {
const mediaData = getCustomEmojiPreviewMediaData(entity.documentId);
const src = mediaData && `src="${mediaData}"`;
return `<img
class="custom-emoji emoji emoji-small"
draggable="false"
alt="${rawText}"
data-document-id="${entity.documentId}"
${src} />`;
}

View File

@ -0,0 +1,90 @@
import { useCallback, useEffect, useState } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ApiSticker } from '../../../../api/types';
import { EMOJI_IMG_REGEX } from '../../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../../util/environment';
import { getHtmlBeforeSelection } from '../../../../util/selection';
import focusEditableElement from '../../../../util/focusEditableElement';
import twemojiRegex from '../../../../lib/twemojiRegex';
import { buildCustomEmojiHtml } from '../helpers/customEmoji';
import useOnSelectionChange from '../../../../hooks/useOnSelectionChange';
import useCacheBuster from '../../../../hooks/useCacheBuster';
const RE_ENDS_ON_EMOJI = new RegExp(`(${twemojiRegex.source})$`, 'g');
const ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`${EMOJI_IMG_REGEX.source}$`, 'g');
export default function useCustomEmojiTooltip(
isAllowed: boolean,
inputSelector: string,
html: string,
onUpdateHtml: (html: string) => void,
stickers?: ApiSticker[],
isDisabled = false,
) {
const { loadCustomEmojiForEmoji, clearCustomEmojiForEmoji } = getActions();
const [htmlBeforeSelection, setHtmlBeforeSelection] = useState('');
const [cacheBuster, updateCacheBuster] = useCacheBuster();
const handleSelectionChange = useCallback((range: Range) => {
if (range.collapsed) {
updateCacheBuster(); // Update tooltip on cursor move
}
}, [updateCacheBuster]);
useOnSelectionChange(document.querySelector<HTMLDivElement>(inputSelector), handleSelectionChange);
useEffect(() => {
if (!html) {
setHtmlBeforeSelection('');
return;
}
setHtmlBeforeSelection(getHtmlBeforeSelection(document.querySelector<HTMLDivElement>(inputSelector)!));
}, [html, inputSelector, cacheBuster]);
const lastEmojiText = htmlBeforeSelection.match(IS_EMOJI_SUPPORTED ? RE_ENDS_ON_EMOJI : ENDS_ON_EMOJI_IMG_REGEX)?.[0];
const hasStickers = Boolean(stickers?.length && lastEmojiText);
useEffect(() => {
if (isDisabled) return;
if (isAllowed && lastEmojiText) {
loadCustomEmojiForEmoji({
emoji: IS_EMOJI_SUPPORTED ? lastEmojiText : lastEmojiText.match(/.+alt="(.+)"/)?.[1]!,
});
} else if (hasStickers || !lastEmojiText) {
clearCustomEmojiForEmoji();
}
// We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via <Esc>).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEmojiText, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji, isAllowed, isDisabled]);
const insertCustomEmoji = useCallback((emoji: ApiSticker) => {
if (!lastEmojiText) return;
const containerEl = document.querySelector<HTMLDivElement>(inputSelector)!;
const regexText = IS_EMOJI_SUPPORTED ? lastEmojiText
// Escape regexp special chars
: lastEmojiText.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`(${regexText})\\1*$`, '');
const matched = htmlBeforeSelection.match(regex)![0];
const count = matched.length / lastEmojiText.length;
const newHtml = htmlBeforeSelection.replace(regex, buildCustomEmojiHtml(emoji).repeat(count));
const htmlAfterSelection = containerEl.innerHTML.substring(htmlBeforeSelection.length);
onUpdateHtml(`${newHtml}${htmlAfterSelection}`);
requestAnimationFrame(() => {
focusEditableElement(containerEl, true, true);
});
}, [htmlBeforeSelection, inputSelector, lastEmojiText, onUpdateHtml]);
return {
isCustomEmojiTooltipOpen: hasStickers,
closeCustomEmojiTooltip: clearCustomEmojiForEmoji,
insertCustomEmoji,
};
}

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; import type { ApiFormattedText, ApiMessage } from '../../../../api/types';
import { ApiMessageEntityTypes } from '../../../../api/types';
import { DRAFT_DEBOUNCE, EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config'; import { DRAFT_DEBOUNCE, EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config';
import usePrevious from '../../../../hooks/usePrevious'; import usePrevious from '../../../../hooks/usePrevious';
@ -26,7 +27,7 @@ const useDraft = (
editedMessage: ApiMessage | undefined, editedMessage: ApiMessage | undefined,
lastSyncTime?: number, lastSyncTime?: number,
) => { ) => {
const { saveDraft, clearDraft } = getActions(); const { saveDraft, clearDraft, loadCustomEmojis } = getActions();
const prevDraft = usePrevious(draft); const prevDraft = usePrevious(draft);
const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => { const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => {
@ -73,6 +74,11 @@ const useDraft = (
setHtml(getTextWithEntitiesAsHtml(draft)); setHtml(getTextWithEntitiesAsHtml(draft));
const customEmojiIds = draft.entities
?.map((entity) => entity.type === ApiMessageEntityTypes.CustomEmoji && entity.documentId)
.filter(Boolean) || [];
if (customEmojiIds.length) loadCustomEmojis({ ids: customEmojiIds });
if (!IS_TOUCH_ENV) { if (!IS_TOUCH_ENV) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR); const messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR);
@ -81,7 +87,9 @@ const useDraft = (
} }
}); });
} }
}, [chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId, editedMessage, prevDraft]); }, [
chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId, editedMessage, prevDraft, loadCustomEmojis,
]);
const html = htmlRef.current; const html = htmlRef.current;
// Update draft when input changes // Update draft when input changes

View File

@ -1,19 +1,26 @@
import { import {
useCallback, useEffect, useState, useCallback, useEffect, useState,
} from '../../../../lib/teact/teact'; } from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global';
import type { ApiSticker } from '../../../../api/types';
import type { EmojiData, EmojiModule, EmojiRawData } from '../../../../util/emoji';
import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID } from '../../../../config'; import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID } from '../../../../config';
import {
buildCollectionByKey, mapValues, pickTruthy, unique, uniqueByField,
} from '../../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
import type { EmojiData, EmojiModule, EmojiRawData } from '../../../../util/emoji';
import { uncompressEmoji } from '../../../../util/emoji'; import { uncompressEmoji } from '../../../../util/emoji';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import {
buildCollectionByKey, mapValues, pickTruthy, unique,
} from '../../../../util/iteratees';
import memoized from '../../../../util/memoized'; import memoized from '../../../../util/memoized';
import useFlag from '../../../../hooks/useFlag';
import renderText from '../../../common/helpers/renderText'; import renderText from '../../../common/helpers/renderText';
import { selectCustomEmojiForEmojis } from '../../../../global/selectors';
import { buildCustomEmojiHtml } from '../helpers/customEmoji';
import useFlag from '../../../../hooks/useFlag';
import useDebouncedCallback from '../../../../hooks/useDebouncedCallback';
interface Library { interface Library {
keywords: string[]; keywords: string[];
@ -30,6 +37,8 @@ let RE_EMOJI_SEARCH: RegExp;
const EMOJIS_LIMIT = 36; const EMOJIS_LIMIT = 36;
const FILTER_MIN_LENGTH = 2; const FILTER_MIN_LENGTH = 2;
const DEBOUNCE = 300;
const prepareRecentEmojisMemo = memoized(prepareRecentEmojis); const prepareRecentEmojisMemo = memoized(prepareRecentEmojis);
const prepareLibraryMemo = memoized(prepareLibrary); const prepareLibraryMemo = memoized(prepareLibrary);
const searchInLibraryMemo = memoized(searchInLibrary); const searchInLibraryMemo = memoized(searchInLibrary);
@ -54,7 +63,12 @@ export default function useEmojiTooltip(
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [byId, setById] = useState<Record<string, Emoji> | undefined>(); const [byId, setById] = useState<Record<string, Emoji> | undefined>();
const [shouldForceInsertEmoji, setShouldForceInsertEmoji] = useState(false); const [shouldForceInsertEmoji, setShouldForceInsertEmoji] = useState(false);
const [filteredEmojis, setFilteredEmojis] = useState<Emoji[]>(MEMO_EMPTY_ARRAY); const [filteredEmojis, setFilteredEmojisInner] = useState<Emoji[]>(MEMO_EMPTY_ARRAY);
const [filteredCustomEmojis, setFilteredCustomEmojis] = useState<ApiSticker[]>(MEMO_EMPTY_ARRAY);
const setFilteredEmojis = useDebouncedCallback((emojis: Emoji[]) => {
setFilteredEmojisInner(emojis);
}, [], DEBOUNCE);
// Initialize data on first render. // Initialize data on first render.
useEffect(() => { useEffect(() => {
@ -72,6 +86,15 @@ export default function useEmojiTooltip(
}, [isDisabled]); }, [isDisabled]);
const html = htmlRef.current; const html = htmlRef.current;
useEffect(() => {
if (isDisabled) return;
const customEmojis = uniqueByField(
selectCustomEmojiForEmojis(getGlobal(), filteredEmojis.map((emoji) => emoji.native)),
'id',
);
setFilteredCustomEmojis(customEmojis);
}, [filteredEmojis, isDisabled]);
useEffect(() => { useEffect(() => {
if (!isAllowed || !html || !byId || isDisabled) { if (!isAllowed || !html || !byId || isDisabled) {
unmarkIsOpen(); unmarkIsOpen();
@ -108,7 +131,7 @@ export default function useEmojiTooltip(
} }
}, [ }, [
byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji, byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji,
isDisabled, baseEmojiKeywords, emojiKeywords, isDisabled, baseEmojiKeywords, emojiKeywords, setFilteredEmojis,
]); ]);
const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => { const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => {
@ -130,6 +153,25 @@ export default function useEmojiTooltip(
unmarkIsOpen(); unmarkIsOpen();
}, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]); }, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]);
const insertCustomEmoji = useCallback((emoji: ApiSticker, isForce?: boolean) => {
const currentHtml = htmlRef.current;
const atIndex = currentHtml.lastIndexOf(':', isForce ? currentHtml.lastIndexOf(':') - 1 : undefined);
if (atIndex !== -1) {
onUpdateHtml(`${currentHtml.substr(0, atIndex)}${buildCustomEmojiHtml(emoji)}`);
let messageInput: HTMLDivElement;
if (inputId === EDITABLE_INPUT_ID) {
messageInput = document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR)!;
} else {
messageInput = document.getElementById(inputId) as HTMLDivElement;
}
requestAnimationFrame(() => {
focusEditableElement(messageInput, true, true);
});
}
unmarkIsOpen();
}, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]);
useEffect(() => { useEffect(() => {
if (isOpen && shouldForceInsertEmoji && filteredEmojis.length) { if (isOpen && shouldForceInsertEmoji && filteredEmojis.length) {
insertEmoji(filteredEmojis[0].native, true); insertEmoji(filteredEmojis[0].native, true);
@ -140,7 +182,9 @@ export default function useEmojiTooltip(
isEmojiTooltipOpen: isOpen, isEmojiTooltipOpen: isOpen,
closeEmojiTooltip: unmarkIsOpen, closeEmojiTooltip: unmarkIsOpen,
filteredEmojis, filteredEmojis,
filteredCustomEmojis,
insertEmoji, insertEmoji,
insertCustomEmoji,
}; };
} }

View File

@ -9,10 +9,11 @@ import { EDITABLE_INPUT_ID } from '../../../../config';
import { filterUsersByName, getUserFirstOrLastName } from '../../../../global/helpers'; import { filterUsersByName, getUserFirstOrLastName } from '../../../../global/helpers';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import useFlag from '../../../../hooks/useFlag';
import { pickTruthy, unique } from '../../../../util/iteratees'; import { pickTruthy, unique } from '../../../../util/iteratees';
import { throttle } from '../../../../util/schedulers'; import { throttle } from '../../../../util/schedulers';
import useFlag from '../../../../hooks/useFlag';
const runThrottled = throttle((cb) => cb(), 500, true); const runThrottled = throttle((cb) => cb(), 500, true);
let RE_USERNAME_SEARCH: RegExp; let RE_USERNAME_SEARCH: RegExp;

View File

@ -3,11 +3,13 @@ import { getActions } from '../../../../global';
import type { ApiSticker } from '../../../../api/types'; import type { ApiSticker } from '../../../../api/types';
import { EMOJI_IMG_REGEX } from '../../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; import { IS_EMOJI_SUPPORTED } from '../../../../util/environment';
import parseEmojiOnlyString from '../../../common/helpers/parseEmojiOnlyString'; import parseEmojiOnlyString from '../../../common/helpers/parseEmojiOnlyString';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
const STARTS_ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`^${EMOJI_IMG_REGEX.source}$`, 'g');
export default function useStickerTooltip( export default function useStickerTooltip(
isAllowed: boolean, isAllowed: boolean,
html: string, html: string,
@ -18,7 +20,7 @@ export default function useStickerTooltip(
const { loadStickersForEmoji, clearStickersForEmoji } = getActions(); const { loadStickersForEmoji, clearStickersForEmoji } = getActions();
const isSingleEmoji = ( const isSingleEmoji = (
(IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1) (IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1)
|| (!IS_EMOJI_SUPPORTED && Boolean(html.match(/^<img.[^>]*?>$/g))) || (!IS_EMOJI_SUPPORTED && Boolean(html.match(STARTS_ENDS_ON_EMOJI_IMG_REGEX)))
); );
const hasStickers = Boolean(stickers?.length) && isSingleEmoji; const hasStickers = Boolean(stickers?.length) && isSingleEmoji;

View File

@ -5,7 +5,7 @@ import type { ApiMessage } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types';
import { getStickerDimensions } from '../../common/helpers/mediaDimensions'; import { getStickerDimensions } from '../../common/helpers/mediaDimensions';
import { getMessageMediaFormat, getMessageMediaHash } from '../../../global/helpers'; import { getMessageMediaFormat, getMessageMediaHash, getStickerPreviewHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { IS_WEBM_SUPPORTED } from '../../../util/environment'; import { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
@ -63,7 +63,7 @@ const Sticker: FC<OwnProps> = ({
const mediaHashEffect = `sticker${sticker.id}?size=f`; const mediaHashEffect = `sticker${sticker.id}?size=f`;
const previewMediaHash = isVideo && !canDisplayVideo && ( const previewMediaHash = isVideo && !canDisplayVideo && (
sticker.isPreloadedGlobally ? `sticker${sticker.id}?size=m` : getMessageMediaHash(message, 'pictogram')); sticker.isPreloadedGlobally ? getStickerPreviewHash(sticker.id) : getMessageMediaHash(message, 'pictogram'));
const previewBlobUrl = useMedia(previewMediaHash); const previewBlobUrl = useMedia(previewMediaHash);
const thumbDataUri = useThumbnail(sticker); const thumbDataUri = useThumbnail(sticker);
const previewUrl = previewBlobUrl || thumbDataUri; const previewUrl = previewBlobUrl || thumbDataUri;

View File

@ -40,6 +40,8 @@ export const MEDIA_CACHE_NAME = 'tt-media';
export const MEDIA_CACHE_NAME_AVATARS = 'tt-media-avatars'; export const MEDIA_CACHE_NAME_AVATARS = 'tt-media-avatars';
export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false; export const MEDIA_PROGRESSIVE_CACHE_DISABLED = false;
export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive'; export const MEDIA_PROGRESSIVE_CACHE_NAME = 'tt-media-progressive';
export const CUSTOM_EMOJI_PREVIEW_CACHE_DISABLED = false;
export const CUSTOM_EMOJI_PREVIEW_CACHE_NAME = 'tt-custom-emoji-preview';
export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB
export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg'; export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
export const LANG_CACHE_NAME = 'tt-lang-packs-v14'; export const LANG_CACHE_NAME = 'tt-lang-packs-v14';
@ -155,6 +157,7 @@ export const RECENT_SYMBOL_SET_ID = 'recent';
export const FAVORITE_SYMBOL_SET_ID = 'favorite'; export const FAVORITE_SYMBOL_SET_ID = 'favorite';
export const CHAT_STICKER_SET_ID = 'chatStickers'; export const CHAT_STICKER_SET_ID = 'chatStickers';
export const PREMIUM_STICKER_SET_ID = 'premium'; export const PREMIUM_STICKER_SET_ID = 'premium';
export const EMOJI_IMG_REGEX = /<img[^>]+alt="([^"]+)"(?![^>]*data-document-id)[^>]*>/gm;
export const BASE_EMOJI_KEYWORD_LANG = 'en'; export const BASE_EMOJI_KEYWORD_LANG = 'en';

View File

@ -13,12 +13,14 @@ import {
updateGifSearch, updateGifSearch,
updateStickersForEmoji, updateStickersForEmoji,
rebuildStickersForEmoji, rebuildStickersForEmoji,
updateCustomEmojiForEmoji,
} from '../../reducers'; } from '../../reducers';
import searchWords from '../../../util/searchWords'; import searchWords from '../../../util/searchWords';
import { selectIsCurrentUserPremium, selectStickerSet } from '../../selectors'; import { selectIsCurrentUserPremium, selectStickerSet } from '../../selectors';
import { getTranslation } from '../../../util/langProvider'; import { getTranslation } from '../../../util/langProvider';
import { selectCurrentLimit, selectPremiumLimit } from '../../selectors/limits'; import { selectCurrentLimit, selectPremiumLimit } from '../../selectors/limits';
import * as langProvider from '../../../util/langProvider'; import * as langProvider from '../../../util/langProvider';
import { buildCollectionByKey } from '../../../util/iteratees';
const ADDED_SETS_THROTTLE = 200; const ADDED_SETS_THROTTLE = 200;
const ADDED_SETS_THROTTLE_CHUNK = 10; const ADDED_SETS_THROTTLE_CHUNK = 10;
@ -548,6 +550,49 @@ addActionHandler('clearStickersForEmoji', (global) => {
}; };
}); });
addActionHandler('loadCustomEmojiForEmoji', (global, actions, payload) => {
const { emoji } = payload;
return updateCustomEmojiForEmoji(global, emoji);
});
addActionHandler('clearCustomEmojiForEmoji', (global) => {
return {
...global,
customEmojis: {
...global.customEmojis,
forEmoji: {},
},
};
});
addActionHandler('loadFeaturedEmojiStickers', async (global) => {
const featuredStickers = await callApi('fetchFeaturedEmojiStickers');
if (!featuredStickers) {
return;
}
global = getGlobal();
setGlobal({
...global,
customEmojis: {
...global.customEmojis,
featuredIds: featuredStickers.sets.map(({ id }) => id),
byId: {
...global.customEmojis.byId,
...buildCollectionByKey(featuredStickers.sets.flatMap((set) => set.stickers || []), 'id'),
},
},
stickers: {
...global.stickers,
setsById: {
...global.stickers.setsById,
...buildCollectionByKey(featuredStickers.sets, 'id'),
},
},
});
});
addActionHandler('openStickerSet', async (global, actions, payload) => { addActionHandler('openStickerSet', async (global, actions, payload) => {
const { stickerSetInfo } = payload; const { stickerSetInfo } = payload;
if (!selectStickerSet(global, stickerSetInfo)) { if (!selectStickerSet(global, stickerSetInfo)) {

View File

@ -192,6 +192,35 @@ addActionHandler('addRecentSticker', (global, action, payload) => {
}; };
}); });
addActionHandler('addRecentCustomEmoji', (global, action, payload) => {
const { documentId } = payload;
const { recentCustomEmojis } = global;
if (!recentCustomEmojis) {
return {
...global,
recentCustomEmojis: [documentId],
};
}
const newEmojis = recentCustomEmojis.filter((id) => id !== documentId);
newEmojis.unshift(documentId);
if (newEmojis.length > MAX_STORED_EMOJIS) {
newEmojis.pop();
}
return {
...global,
recentCustomEmojis: newEmojis,
};
});
addActionHandler('clearRecentCustomEmoji', (global) => {
return {
...global,
recentCustomEmojis: [],
};
});
addActionHandler('reorderStickerSets', (global, action, payload) => { addActionHandler('reorderStickerSets', (global, action, payload) => {
const { order, isCustomEmoji } = payload; const { order, isCustomEmoji } = payload;
return { return {

View File

@ -280,15 +280,28 @@ export function migrateCache(cached: GlobalState, initialState: GlobalState) {
added: {}, added: {},
byId: {}, byId: {},
lastRendered: [], lastRendered: [],
forEmoji: {},
}; };
} }
if (!cached.recentCustomEmojis) {
cached.recentCustomEmojis = [];
}
if (cached.settings.byKey.shouldSuggestCustomEmoji === undefined) {
cached.settings.byKey.shouldSuggestCustomEmoji = true;
}
if (!cached.stickers.premiumSet) { if (!cached.stickers.premiumSet) {
cached.stickers.premiumSet = { cached.stickers.premiumSet = {
stickers: [], stickers: [],
}; };
} }
if (!cached.customEmojis.forEmoji) {
cached.customEmojis.forEmoji = {};
}
// TODO Remove in Jan 2023 (this was re-designed but can be hardcoded in cache) // TODO Remove in Jan 2023 (this was re-designed but can be hardcoded in cache)
const { light: lightTheme } = cached.settings.themes; const { light: lightTheme } = cached.settings.themes;
if (lightTheme?.patternColor === 'rgba(90, 110, 70, 0.6)' || !lightTheme?.patternColor) { if (lightTheme?.patternColor === 'rgba(90, 110, 70, 0.6)' || !lightTheme?.patternColor) {
@ -396,6 +409,7 @@ function reduceCustomEmojis(global: GlobalState): GlobalState['customEmojis'] {
return { return {
byId: byIdToSave, byId: byIdToSave,
lastRendered: idsToSave, lastRendered: idsToSave,
forEmoji: {},
added: {}, added: {},
}; };
} }

View File

@ -8,3 +8,4 @@ export * from './payments';
export * from './reactions'; export * from './reactions';
export * from './bots'; export * from './bots';
export * from './media'; export * from './media';
export * from './symbols';

View File

@ -0,0 +1,3 @@
export function getStickerPreviewHash(stickerId: string) {
return `sticker${stickerId}?size=m`;
}

View File

@ -96,6 +96,7 @@ export const INITIAL_STATE: GlobalState = {
lastRendered: [], lastRendered: [],
byId: {}, byId: {},
added: {}, added: {},
forEmoji: {},
}, },
emojiKeywords: {}, emojiKeywords: {},
@ -190,6 +191,7 @@ export const INITIAL_STATE: GlobalState = {
canAutoPlayGifs: true, canAutoPlayGifs: true,
canAutoPlayVideos: true, canAutoPlayVideos: true,
shouldSuggestStickers: true, shouldSuggestStickers: true,
shouldSuggestCustomEmoji: true,
shouldLoopStickers: true, shouldLoopStickers: true,
language: 'en', language: 'en',
timeFormat: '24h', timeFormat: '24h',

View File

@ -1,7 +1,7 @@
import type { GlobalState } from '../types'; import type { GlobalState } from '../types';
import type { ApiSticker, ApiStickerSet, ApiVideo } from '../../api/types'; import type { ApiSticker, ApiStickerSet, ApiVideo } from '../../api/types';
import { buildCollectionByKey, unique } from '../../util/iteratees'; import { buildCollectionByKey, unique } from '../../util/iteratees';
import { selectStickersForEmoji } from '../selectors'; import { selectCustomEmojiForEmoji, selectStickersForEmoji } from '../selectors';
export function updateStickerSets( export function updateStickerSets(
global: GlobalState, global: GlobalState,
@ -176,6 +176,26 @@ export function updateStickersForEmoji(
}; };
} }
export function updateCustomEmojiForEmoji(
global: GlobalState, emoji: string,
): GlobalState {
const localStickers = selectCustomEmojiForEmoji(global, emoji);
const uniqueIds = unique(localStickers.map(({ id }) => id));
const byId = buildCollectionByKey(localStickers, 'id');
const stickers = uniqueIds.map((id) => byId[id]);
return {
...global,
customEmojis: {
...global.customEmojis,
forEmoji: {
emoji,
stickers,
},
},
};
}
export function rebuildStickersForEmoji(global: GlobalState): GlobalState { export function rebuildStickersForEmoji(global: GlobalState): GlobalState {
if (global.stickers.forEmoji) { if (global.stickers.forEmoji) {
const { emoji, stickers, hash } = global.stickers.forEmoji; const { emoji, stickers, hash } = global.stickers.forEmoji;
@ -186,5 +206,14 @@ export function rebuildStickersForEmoji(global: GlobalState): GlobalState {
return updateStickersForEmoji(global, emoji, stickers, hash); return updateStickersForEmoji(global, emoji, stickers, hash);
} }
if (global.customEmojis.forEmoji) {
const { emoji } = global.customEmojis.forEmoji;
if (!emoji) {
return global;
}
return updateCustomEmojiForEmoji(global, emoji);
}
return global; return global;
} }

View File

@ -70,7 +70,27 @@ export function selectCustomEmojiForEmoji(global: GlobalState, emoji: string) {
return isCurrentUserPremium ? customEmojiForEmoji : customEmojiForEmoji.filter(({ isFree }) => isFree); return isCurrentUserPremium ? customEmojiForEmoji : customEmojiForEmoji.filter(({ isFree }) => isFree);
} }
export function selectIsSetPremium(stickerSet: ApiStickerSet) { // Slow, not to be used in `withGlobal`
export function selectCustomEmojiForEmojis(global: GlobalState, emojis: 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;
}
const customEmojis = Object.entries(packs).filter(([emoji]) => (
emojis.includes(emoji) || emojis.includes(cleanEmoji(emoji))
)).flatMap(([, stickers]) => stickers);
customEmojiForEmoji = customEmojiForEmoji.concat(customEmojis);
});
return isCurrentUserPremium ? customEmojiForEmoji : customEmojiForEmoji.filter(({ isFree }) => isFree);
}
export function selectIsSetPremium(stickerSet: Pick<ApiStickerSet, 'stickers' | 'isEmoji'>) {
return stickerSet.isEmoji && stickerSet.stickers?.some((sticker) => !sticker.isFree); return stickerSet.isEmoji && stickerSet.stickers?.some((sticker) => !sticker.isFree);
} }

View File

@ -327,6 +327,11 @@ export type GlobalState = {
}; };
lastRendered: string[]; lastRendered: string[];
byId: Record<string, ApiSticker>; byId: Record<string, ApiSticker>;
forEmoji: {
emoji?: string;
stickers?: ApiSticker[];
};
featuredIds?: string[];
}; };
animatedEmojis?: ApiStickerSet; animatedEmojis?: ApiStickerSet;
@ -926,6 +931,11 @@ export interface ActionPayloads {
}; };
clearStickersForEmoji: never; clearStickersForEmoji: never;
loadCustomEmojiForEmoji: {
emoji: string;
};
clearCustomEmojiForEmoji: never;
addRecentEmoji: { addRecentEmoji: {
emoji: string; emoji: string;
}; };
@ -941,6 +951,11 @@ export interface ActionPayloads {
setIds: string[]; setIds: string[];
}; };
closeCustomEmojiSets: never; closeCustomEmojiSets: never;
addRecentCustomEmoji: {
documentId: string;
};
clearRecentCustomEmoji: never;
loadFeaturedEmojiStickers: never;
// Bots // Bots
startBot: { startBot: {

View File

@ -0,0 +1,23 @@
import { useEffect } from '../lib/teact/teact';
export default function useOnSelectionChange(container: HTMLElement | null, callback: (range: Range) => void) {
useEffect(() => {
if (!container) return undefined;
const onSelectionChange = () => {
const selection = window.getSelection();
if (!selection) return;
for (let i = 0; i < selection.rangeCount; i++) {
const range = selection.getRangeAt(i);
const ancestor = range.commonAncestorContainer;
if (container.contains(ancestor)) {
callback(range);
}
}
};
document.addEventListener('selectionchange', onSelectionChange);
return () => document.removeEventListener('selectionchange', onSelectionChange);
}, [callback, container]);
}

View File

@ -598,21 +598,40 @@ class TelegramClient {
} }
downloadStickerSetThumb(stickerSet) { downloadStickerSetThumb(stickerSet) {
if (!stickerSet.thumbs || !stickerSet.thumbs.length) { if (!stickerSet.thumbs?.length && !stickerSet.thumbDocumentId) {
return undefined; return undefined;
} }
const { thumbVersion } = stickerSet; const { thumbVersion } = stickerSet;
return this.downloadFile(
new constructors.InputStickerSetThumb({ if (!stickerSet.thumbDocumentId) {
stickerset: new constructors.InputStickerSetID({ return this.downloadFile(
id: stickerSet.id, new constructors.InputStickerSetThumb({
accessHash: stickerSet.accessHash, stickerset: new constructors.InputStickerSetID({
id: stickerSet.id,
accessHash: stickerSet.accessHash,
}),
thumbVersion,
}), }),
thumbVersion, { dcId: stickerSet.thumbDcId },
);
}
return this.invoke(new constructors.messages.GetCustomEmojiDocuments({
documentId: [stickerSet.thumbDocumentId],
})).then((docs) => {
const doc = docs[0];
return this.downloadFile(new constructors.InputDocumentFileLocation({
id: doc.id,
accessHash: doc.accessHash,
fileReference: doc.fileReference,
thumbSize: '',
}), }),
{ dcId: stickerSet.thumbDcId }, {
); fileSize: doc.size.toJSNumber(),
dcId: doc.dcId,
});
});
} }
_pickFileSize(sizes, sizeType) { _pickFileSize(sizes, sizeType) {

View File

@ -51,7 +51,7 @@ export async function respondForDownload(e: FetchEvent) {
const filenameHeader = matchedFilename ? `filename="${decodeURIComponent(matchedFilename[1])}"` : ''; const filenameHeader = matchedFilename ? `filename="${decodeURIComponent(matchedFilename[1])}"` : '';
const { fullSize, mimeType } = partInfo; const { fullSize, mimeType } = partInfo;
const headers = [ const headers: [string, string][] = [
['Content-Length', String(fullSize)], ['Content-Length', String(fullSize)],
['Content-Type', mimeType], ['Content-Type', mimeType],
['Content-Disposition', `attachment; ${filenameHeader}`], ['Content-Disposition', `attachment; ${filenameHeader}`],

View File

@ -100,7 +100,7 @@ export async function respondForProgressive(e: FetchEvent) {
const partSize = Math.min(end - start + 1, arrayBuffer.byteLength); const partSize = Math.min(end - start + 1, arrayBuffer.byteLength);
end = start + partSize - 1; end = start + partSize - 1;
const arrayBufferPart = arrayBuffer.slice(0, partSize); const arrayBufferPart = arrayBuffer.slice(0, partSize);
const headers = [ const headers: [string, string][] = [
['Content-Range', `bytes ${start}-${end}/${fullSize}`], ['Content-Range', `bytes ${start}-${end}/${fullSize}`],
['Accept-Ranges', 'bytes'], ['Accept-Ranges', 'bytes'],
['Content-Length', String(partSize)], ['Content-Length', String(partSize)],

View File

@ -78,6 +78,7 @@ export interface ISettings extends NotifySettings, Record<string, any> {
canAutoPlayGifs: boolean; canAutoPlayGifs: boolean;
canAutoPlayVideos: boolean; canAutoPlayVideos: boolean;
shouldSuggestStickers: boolean; shouldSuggestStickers: boolean;
shouldSuggestCustomEmoji: boolean;
shouldLoopStickers: boolean; shouldLoopStickers: boolean;
hasPassword?: boolean; hasPassword?: boolean;
languages?: ApiLanguage[]; languages?: ApiLanguage[];

View File

@ -0,0 +1,31 @@
import { ApiMediaFormat } from '../api/types';
import { getStickerPreviewHash } from '../global/helpers';
import * as mediaLoader from './mediaLoader';
import { throttle } from './schedulers';
const DOM_PROCESS_THROTTLE = 500;
function processDomForCustomEmoji() {
const emojis = document.querySelectorAll<HTMLImageElement>('img[data-document-id]:not([src])');
emojis.forEach((emoji) => {
const mediaHash = getStickerPreviewHash(emoji.dataset.documentId!);
const mediaData = mediaLoader.getFromMemory(mediaHash);
if (mediaData) {
emoji.src = mediaData;
}
});
}
export const processMessageInputForCustomEmoji = throttle(processDomForCustomEmoji, DOM_PROCESS_THROTTLE);
export function getCustomEmojiPreviewMediaData(emojiId: string) {
const mediaHash = getStickerPreviewHash(emojiId);
const data = mediaLoader.getFromMemory(mediaHash);
if (data) {
return data;
}
mediaLoader.fetch(mediaHash, ApiMediaFormat.BlobUrl).then(() => processMessageInputForCustomEmoji());
return undefined;
}

View File

@ -1,6 +1,5 @@
import type { ApiMessageEntity, ApiFormattedText } from '../api/types'; import type { ApiMessageEntity, ApiFormattedText } from '../api/types';
import { ApiMessageEntityTypes } from '../api/types'; import { ApiMessageEntityTypes } from '../api/types';
import { IS_EMOJI_SUPPORTED } from './environment';
import { RE_LINK_TEMPLATE } from '../config'; import { RE_LINK_TEMPLATE } from '../config';
const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = { const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
@ -15,7 +14,6 @@ const ENTITY_CLASS_BY_NODE_NAME: Record<string, ApiMessageEntityTypes> = {
CODE: ApiMessageEntityTypes.Code, CODE: ApiMessageEntityTypes.Code,
PRE: ApiMessageEntityTypes.Pre, PRE: ApiMessageEntityTypes.Pre,
BLOCKQUOTE: ApiMessageEntityTypes.Blockquote, BLOCKQUOTE: ApiMessageEntityTypes.Blockquote,
'CUSTOM-EMOJI': ApiMessageEntityTypes.CustomEmoji,
}; };
const MAX_TAG_DEEPNESS = 3; const MAX_TAG_DEEPNESS = 3;
@ -23,6 +21,7 @@ const MAX_TAG_DEEPNESS = 3;
export default function parseMessageInput(html: string, withMarkdownLinks = false): ApiFormattedText { export default function parseMessageInput(html: string, withMarkdownLinks = false): ApiFormattedText {
const fragment = document.createElement('div'); const fragment = document.createElement('div');
fragment.innerHTML = withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html); fragment.innerHTML = withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
fixImageContent(fragment);
const text = fragment.innerText.trim().replace(/\u200b+/g, ''); const text = fragment.innerText.trim().replace(/\u200b+/g, '');
let textIndex = 0; let textIndex = 0;
let recursionDeepness = 0; let recursionDeepness = 0;
@ -59,14 +58,19 @@ export default function parseMessageInput(html: string, withMarkdownLinks = fals
}; };
} }
function fixImageContent(fragment: HTMLDivElement) {
fragment.querySelectorAll('img').forEach((node) => {
if (node.dataset.documentId) { // Custom Emoji
node.textContent = (node as HTMLImageElement).alt || '';
} else { // Regular emoji with image fallback
node.replaceWith(node.alt || '');
}
});
}
function parseMarkdown(html: string) { function parseMarkdown(html: string) {
let parsedHtml = html.slice(0); let parsedHtml = html.slice(0);
if (!IS_EMOJI_SUPPORTED) {
// Emojis
parsedHtml = parsedHtml.replace(/<img[^>]+alt="([^"]+)"[^>]*>/gm, '$1');
}
// Strip redundant nbsp's // Strip redundant nbsp's
parsedHtml = parsedHtml.replace(/&nbsp;/g, ' '); parsedHtml = parsedHtml.replace(/&nbsp;/g, ' ');
@ -94,7 +98,7 @@ function parseMarkdown(html: string) {
// Custom Emoji markdown tag // Custom Emoji markdown tag
parsedHtml = parsedHtml.replace( parsedHtml = parsedHtml.replace(
/(^|\s)(?!<(?:code|pre)[^<]*|<\/)\[([^\]\n]+)\]\(customEmoji:(\d+)\)(?![^<]*<\/(?:code|pre)>)(\s|$)/g, /(^|\s)(?!<(?:code|pre)[^<]*|<\/)\[([^\]\n]+)\]\(customEmoji:(\d+)\)(?![^<]*<\/(?:code|pre)>)(\s|$)/g,
'$1<custom-emoji document-id="$3" alt="$2">$2</custom-emoji>$4', '$1<img alt="$2" data-document-id="$3">$4',
); );
// Other simple markdown // Other simple markdown
@ -131,6 +135,7 @@ function getEntityDataFromNode(
textIndex: number, textIndex: number,
): { index: number; entity?: ApiMessageEntity } { ): { index: number; entity?: ApiMessageEntity } {
const type = getEntityTypeFromNode(node); const type = getEntityTypeFromNode(node);
if (!type || !node.textContent) { if (!type || !node.textContent) {
return { return {
index: textIndex, index: textIndex,
@ -187,7 +192,7 @@ function getEntityDataFromNode(
type, type,
offset, offset,
length, length,
documentId: (node as HTMLElement).getAttribute('document-id')!, documentId: (node as HTMLImageElement).dataset.documentId!,
}, },
}; };
} }
@ -232,5 +237,11 @@ function getEntityTypeFromNode(node: ChildNode): ApiMessageEntityTypes | undefin
return (node as HTMLElement).dataset.entityType as any; return (node as HTMLElement).dataset.entityType as any;
} }
if (node.nodeName === 'IMG') {
if ((node as HTMLImageElement).dataset.documentId) {
return ApiMessageEntityTypes.CustomEmoji;
}
}
return undefined; return undefined;
} }

View File

@ -1,3 +1,5 @@
const fragmentEl = document.createElement('div');
export function insertHtmlInSelection(html: string) { export function insertHtmlInSelection(html: string) {
const selection = window.getSelection(); const selection = window.getSelection();
@ -18,3 +20,16 @@ export function insertHtmlInSelection(html: string) {
selection.addRange(range); selection.addRange(range);
} }
} }
export function getHtmlBeforeSelection(container?: HTMLElement, useCommonAncestor?: boolean) {
if (!container) return '';
const sel = window.getSelection();
if (!sel || !sel.rangeCount) return container.innerHTML;
const range = sel.getRangeAt(0).cloneRange();
if (!range.intersectsNode(container)) return container.innerHTML;
if (!useCommonAncestor && !container.contains(range.commonAncestorContainer)) return '';
range.collapse(true);
range.setStart(container, 0);
fragmentEl.replaceChildren(range.cloneContents());
return fragmentEl.innerHTML;
}