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

View File

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

View File

@ -28,7 +28,7 @@ export async function fetchCustomEmojiSets({ hash = '0' }: { hash?: string }) {
}
allStickers.sets.forEach((stickerSet) => {
if (stickerSet.thumbs?.length) {
if (stickerSet.thumbs?.length || stickerSet.thumbDocumentId) {
localDb.stickerSets[String(stickerSet.id)] = stickerSet;
}
});
@ -98,6 +98,25 @@ export async function fetchFeaturedStickers({ hash = '0' }: { hash?: string }) {
};
}
export async function fetchFeaturedEmojiStickers() {
const result = await invokeRequest(new GramJs.messages.GetFeaturedEmojiStickers({ hash: BigInt(0) }));
if (!result || result instanceof GramJs.messages.FeaturedStickersNotModified) {
return undefined;
}
result.sets.forEach(({ set }) => {
if (set.thumbDocumentId) {
localDb.stickerSets[String(set.id)] = set;
}
});
return {
isPremium: Boolean(result.premium),
sets: result.sets.map(buildStickerSetCovered),
};
}
export async function faveSticker({
sticker,
unfave,

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,8 @@ import buildClassName from '../../../util/buildClassName';
import { ensureProtocol } from '../../../util/ensureProtocol';
import captureEscKeyListener from '../../../util/captureEscKeyListener';
import getKeyFromEvent from '../../../util/getKeyFromEvent';
import { INPUT_CUSTOM_EMOJI_SELECTOR } from './helpers/customEmoji';
import useShowTransition from '../../../hooks/useShowTransition';
import useVirtualBackdrop from '../../../hooks/useVirtualBackdrop';
import useFlag from '../../../hooks/useFlag';
@ -129,11 +131,16 @@ const TextFormatter: FC<OwnProps> = ({
}
}, [setSelectedRange]);
const getSelectedText = useCallback(() => {
const getSelectedText = useCallback((shouldDropCustomEmoji?: boolean) => {
if (!selectedRange) {
return undefined;
}
fragmentEl.replaceChildren(selectedRange.cloneContents());
if (shouldDropCustomEmoji) {
fragmentEl.querySelectorAll(INPUT_CUSTOM_EMOJI_SELECTOR).forEach((el) => {
el.replaceWith(el.getAttribute('alt')!);
});
}
return fragmentEl.innerHTML;
}, [selectedRange]);
@ -304,7 +311,7 @@ const TextFormatter: FC<OwnProps> = ({
return;
}
const text = getSelectedText();
const text = getSelectedText(true);
document.execCommand('insertHTML', false, `<code class="text-entity-code" dir="auto">${text}</code>`);
onClose();
}, [
@ -327,7 +334,7 @@ const TextFormatter: FC<OwnProps> = ({
return;
}
const text = getSelectedText();
const text = getSelectedText(true);
restoreSelection();
document.execCommand(
'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 type { ApiFormattedText, ApiMessage } from '../../../../api/types';
import { ApiMessageEntityTypes } from '../../../../api/types';
import { DRAFT_DEBOUNCE, EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config';
import usePrevious from '../../../../hooks/usePrevious';
@ -26,7 +27,7 @@ const useDraft = (
editedMessage: ApiMessage | undefined,
lastSyncTime?: number,
) => {
const { saveDraft, clearDraft } = getActions();
const { saveDraft, clearDraft, loadCustomEmojis } = getActions();
const prevDraft = usePrevious(draft);
const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => {
@ -73,6 +74,11 @@ const useDraft = (
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) {
requestAnimationFrame(() => {
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;
// Update draft when input changes

View File

@ -1,19 +1,26 @@
import {
useCallback, useEffect, useState,
} 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 {
buildCollectionByKey, mapValues, pickTruthy, unique, uniqueByField,
} from '../../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { prepareForRegExp } from '../helpers/prepareForRegExp';
import type { EmojiData, EmojiModule, EmojiRawData } from '../../../../util/emoji';
import { uncompressEmoji } from '../../../../util/emoji';
import focusEditableElement from '../../../../util/focusEditableElement';
import {
buildCollectionByKey, mapValues, pickTruthy, unique,
} from '../../../../util/iteratees';
import memoized from '../../../../util/memoized';
import useFlag from '../../../../hooks/useFlag';
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 {
keywords: string[];
@ -30,6 +37,8 @@ let RE_EMOJI_SEARCH: RegExp;
const EMOJIS_LIMIT = 36;
const FILTER_MIN_LENGTH = 2;
const DEBOUNCE = 300;
const prepareRecentEmojisMemo = memoized(prepareRecentEmojis);
const prepareLibraryMemo = memoized(prepareLibrary);
const searchInLibraryMemo = memoized(searchInLibrary);
@ -54,7 +63,12 @@ export default function useEmojiTooltip(
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [byId, setById] = useState<Record<string, Emoji> | undefined>();
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.
useEffect(() => {
@ -72,6 +86,15 @@ export default function useEmojiTooltip(
}, [isDisabled]);
const html = htmlRef.current;
useEffect(() => {
if (isDisabled) return;
const customEmojis = uniqueByField(
selectCustomEmojiForEmojis(getGlobal(), filteredEmojis.map((emoji) => emoji.native)),
'id',
);
setFilteredCustomEmojis(customEmojis);
}, [filteredEmojis, isDisabled]);
useEffect(() => {
if (!isAllowed || !html || !byId || isDisabled) {
unmarkIsOpen();
@ -108,7 +131,7 @@ export default function useEmojiTooltip(
}
}, [
byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji,
isDisabled, baseEmojiKeywords, emojiKeywords,
isDisabled, baseEmojiKeywords, emojiKeywords, setFilteredEmojis,
]);
const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => {
@ -130,6 +153,25 @@ export default function useEmojiTooltip(
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(() => {
if (isOpen && shouldForceInsertEmoji && filteredEmojis.length) {
insertEmoji(filteredEmojis[0].native, true);
@ -140,7 +182,9 @@ export default function useEmojiTooltip(
isEmojiTooltipOpen: isOpen,
closeEmojiTooltip: unmarkIsOpen,
filteredEmojis,
filteredCustomEmojis,
insertEmoji,
insertCustomEmoji,
};
}

View File

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

View File

@ -3,11 +3,13 @@ import { getActions } from '../../../../global';
import type { ApiSticker } from '../../../../api/types';
import { EMOJI_IMG_REGEX } from '../../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../../util/environment';
import parseEmojiOnlyString from '../../../common/helpers/parseEmojiOnlyString';
import { prepareForRegExp } from '../helpers/prepareForRegExp';
const STARTS_ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`^${EMOJI_IMG_REGEX.source}$`, 'g');
export default function useStickerTooltip(
isAllowed: boolean,
html: string,
@ -18,7 +20,7 @@ export default function useStickerTooltip(
const { loadStickersForEmoji, clearStickersForEmoji } = getActions();
const isSingleEmoji = (
(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;

View File

@ -5,7 +5,7 @@ import type { ApiMessage } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
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 { IS_WEBM_SUPPORTED } from '../../../util/environment';
import { getActions } from '../../../global';
@ -63,7 +63,7 @@ const Sticker: FC<OwnProps> = ({
const mediaHashEffect = `sticker${sticker.id}?size=f`;
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 thumbDataUri = useThumbnail(sticker);
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_PROGRESSIVE_CACHE_DISABLED = false;
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 CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
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 CHAT_STICKER_SET_ID = 'chatStickers';
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';

View File

@ -13,12 +13,14 @@ import {
updateGifSearch,
updateStickersForEmoji,
rebuildStickersForEmoji,
updateCustomEmojiForEmoji,
} from '../../reducers';
import searchWords from '../../../util/searchWords';
import { selectIsCurrentUserPremium, selectStickerSet } from '../../selectors';
import { getTranslation } from '../../../util/langProvider';
import { selectCurrentLimit, selectPremiumLimit } from '../../selectors/limits';
import * as langProvider from '../../../util/langProvider';
import { buildCollectionByKey } from '../../../util/iteratees';
const ADDED_SETS_THROTTLE = 200;
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) => {
const { stickerSetInfo } = payload;
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) => {
const { order, isCustomEmoji } = payload;
return {

View File

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

View File

@ -8,3 +8,4 @@ export * from './payments';
export * from './reactions';
export * from './bots';
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: [],
byId: {},
added: {},
forEmoji: {},
},
emojiKeywords: {},
@ -190,6 +191,7 @@ export const INITIAL_STATE: GlobalState = {
canAutoPlayGifs: true,
canAutoPlayVideos: true,
shouldSuggestStickers: true,
shouldSuggestCustomEmoji: true,
shouldLoopStickers: true,
language: 'en',
timeFormat: '24h',

View File

@ -1,7 +1,7 @@
import type { GlobalState } from '../types';
import type { ApiSticker, ApiStickerSet, ApiVideo } from '../../api/types';
import { buildCollectionByKey, unique } from '../../util/iteratees';
import { selectStickersForEmoji } from '../selectors';
import { selectCustomEmojiForEmoji, selectStickersForEmoji } from '../selectors';
export function updateStickerSets(
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 {
if (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);
}
if (global.customEmojis.forEmoji) {
const { emoji } = global.customEmojis.forEmoji;
if (!emoji) {
return global;
}
return updateCustomEmojiForEmoji(global, emoji);
}
return global;
}

View File

@ -70,7 +70,27 @@ export function selectCustomEmojiForEmoji(global: GlobalState, emoji: string) {
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);
}

View File

@ -327,6 +327,11 @@ export type GlobalState = {
};
lastRendered: string[];
byId: Record<string, ApiSticker>;
forEmoji: {
emoji?: string;
stickers?: ApiSticker[];
};
featuredIds?: string[];
};
animatedEmojis?: ApiStickerSet;
@ -926,6 +931,11 @@ export interface ActionPayloads {
};
clearStickersForEmoji: never;
loadCustomEmojiForEmoji: {
emoji: string;
};
clearCustomEmojiForEmoji: never;
addRecentEmoji: {
emoji: string;
};
@ -941,6 +951,11 @@ export interface ActionPayloads {
setIds: string[];
};
closeCustomEmojiSets: never;
addRecentCustomEmoji: {
documentId: string;
};
clearRecentCustomEmoji: never;
loadFeaturedEmojiStickers: never;
// Bots
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) {
if (!stickerSet.thumbs || !stickerSet.thumbs.length) {
if (!stickerSet.thumbs?.length && !stickerSet.thumbDocumentId) {
return undefined;
}
const { thumbVersion } = stickerSet;
return this.downloadFile(
new constructors.InputStickerSetThumb({
stickerset: new constructors.InputStickerSetID({
id: stickerSet.id,
accessHash: stickerSet.accessHash,
if (!stickerSet.thumbDocumentId) {
return this.downloadFile(
new constructors.InputStickerSetThumb({
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) {

View File

@ -51,7 +51,7 @@ export async function respondForDownload(e: FetchEvent) {
const filenameHeader = matchedFilename ? `filename="${decodeURIComponent(matchedFilename[1])}"` : '';
const { fullSize, mimeType } = partInfo;
const headers = [
const headers: [string, string][] = [
['Content-Length', String(fullSize)],
['Content-Type', mimeType],
['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);
end = start + partSize - 1;
const arrayBufferPart = arrayBuffer.slice(0, partSize);
const headers = [
const headers: [string, string][] = [
['Content-Range', `bytes ${start}-${end}/${fullSize}`],
['Accept-Ranges', 'bytes'],
['Content-Length', String(partSize)],

View File

@ -78,6 +78,7 @@ export interface ISettings extends NotifySettings, Record<string, any> {
canAutoPlayGifs: boolean;
canAutoPlayVideos: boolean;
shouldSuggestStickers: boolean;
shouldSuggestCustomEmoji: boolean;
shouldLoopStickers: boolean;
hasPassword?: boolean;
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 { ApiMessageEntityTypes } from '../api/types';
import { IS_EMOJI_SUPPORTED } from './environment';
import { RE_LINK_TEMPLATE } from '../config';
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,
PRE: ApiMessageEntityTypes.Pre,
BLOCKQUOTE: ApiMessageEntityTypes.Blockquote,
'CUSTOM-EMOJI': ApiMessageEntityTypes.CustomEmoji,
};
const MAX_TAG_DEEPNESS = 3;
@ -23,6 +21,7 @@ const MAX_TAG_DEEPNESS = 3;
export default function parseMessageInput(html: string, withMarkdownLinks = false): ApiFormattedText {
const fragment = document.createElement('div');
fragment.innerHTML = withMarkdownLinks ? parseMarkdown(parseMarkdownLinks(html)) : parseMarkdown(html);
fixImageContent(fragment);
const text = fragment.innerText.trim().replace(/\u200b+/g, '');
let textIndex = 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) {
let parsedHtml = html.slice(0);
if (!IS_EMOJI_SUPPORTED) {
// Emojis
parsedHtml = parsedHtml.replace(/<img[^>]+alt="([^"]+)"[^>]*>/gm, '$1');
}
// Strip redundant nbsp's
parsedHtml = parsedHtml.replace(/&nbsp;/g, ' ');
@ -94,7 +98,7 @@ function parseMarkdown(html: string) {
// Custom Emoji markdown tag
parsedHtml = parsedHtml.replace(
/(^|\s)(?!<(?:code|pre)[^<]*|<\/)\[([^\]\n]+)\]\(customEmoji:(\d+)\)(?![^<]*<\/(?:code|pre)>)(\s|$)/g,
'$1<custom-emoji document-id="$3" alt="$2">$2</custom-emoji>$4',
'$1<img alt="$2" data-document-id="$3">$4',
);
// Other simple markdown
@ -131,6 +135,7 @@ function getEntityDataFromNode(
textIndex: number,
): { index: number; entity?: ApiMessageEntity } {
const type = getEntityTypeFromNode(node);
if (!type || !node.textContent) {
return {
index: textIndex,
@ -187,7 +192,7 @@ function getEntityDataFromNode(
type,
offset,
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;
}
if (node.nodeName === 'IMG') {
if ((node as HTMLImageElement).dataset.documentId) {
return ApiMessageEntityTypes.CustomEmoji;
}
}
return undefined;
}

View File

@ -1,3 +1,5 @@
const fragmentEl = document.createElement('div');
export function insertHtmlInSelection(html: string) {
const selection = window.getSelection();
@ -18,3 +20,16 @@ export function insertHtmlInSelection(html: string) {
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;
}