Support sending custom emoji (#2000)
This commit is contained in:
parent
76c1816eba
commit
c365881d57
@ -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,
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -547,6 +547,11 @@
|
||||
vertical-align: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.custom-emoji {
|
||||
--custom-emoji-size: 1.25rem;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
|
||||
#caption-input-text {
|
||||
|
||||
@ -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,
|
||||
|
||||
46
src/components/middle/composer/CustomEmojiButton.tsx
Normal file
46
src/components/middle/composer/CustomEmojiButton.tsx
Normal 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);
|
||||
298
src/components/middle/composer/CustomEmojiPicker.tsx
Normal file
298
src/components/middle/composer/CustomEmojiPicker.tsx
Normal 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));
|
||||
16
src/components/middle/composer/CustomEmojiTooltip.async.tsx
Normal file
16
src/components/middle/composer/CustomEmojiTooltip.async.tsx
Normal 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);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
116
src/components/middle/composer/CustomEmojiTooltip.tsx
Normal file
116
src/components/middle/composer/CustomEmojiTooltip.tsx
Normal 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));
|
||||
@ -30,4 +30,8 @@
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
& > .custom-emoji {
|
||||
--custom-emoji-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -75,6 +75,10 @@
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.activated {
|
||||
background-color: var(--color-background-selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
26
src/components/middle/composer/helpers/customEmoji.ts
Normal file
26
src/components/middle/composer/helpers/customEmoji.ts
Normal 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} />`;
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,3 +8,4 @@ export * from './payments';
|
||||
export * from './reactions';
|
||||
export * from './bots';
|
||||
export * from './media';
|
||||
export * from './symbols';
|
||||
|
||||
3
src/global/helpers/symbols.ts
Normal file
3
src/global/helpers/symbols.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function getStickerPreviewHash(stickerId: string) {
|
||||
return `sticker${stickerId}?size=m`;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
23
src/hooks/useOnSelectionChange.ts
Normal file
23
src/hooks/useOnSelectionChange.ts
Normal 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]);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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}`],
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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[];
|
||||
|
||||
31
src/util/customEmojiManager.ts
Normal file
31
src/util/customEmojiManager.ts
Normal 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;
|
||||
}
|
||||
@ -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(/ /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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user