Introduce Emoji statuses (#2329)

This commit is contained in:
Alexander Zinchuk 2023-02-13 03:32:19 +01:00
parent 88f9920fd5
commit d6e4bac389
49 changed files with 752 additions and 150 deletions

35
package-lock.json generated
View File

@ -104,6 +104,7 @@
}
},
"dev/eslint-multitab": {
"name": "eslint-plugin-eslint-multitab-tt",
"version": "0.0.0",
"dev": true,
"license": "ISC",
@ -7185,15 +7186,15 @@
}
},
"node_modules/eslint-doc-generator": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/eslint-doc-generator/-/eslint-doc-generator-1.4.3.tgz",
"integrity": "sha512-cn9KXE7xuKlxKi/9VbirR3cbz7W1geRObwWzZjJAnpTeNBoqA8Rj+lD8/HHHJ7PnOdaTrRyhhoYdCtxqq3U7Bw==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/eslint-doc-generator/-/eslint-doc-generator-1.4.2.tgz",
"integrity": "sha512-axO5W9Nt/n/cppE75nJ8JVoqmzUyinzhNQ42vJFqjSnK8KeQoWE6IWqqT1wZly5uSneMuuWYYlCGicc0UeJp2g==",
"dev": true,
"dependencies": {
"@typescript-eslint/utils": "^5.38.1",
"ajv": "^8.11.2",
"boolean": "^3.2.0",
"commander": "^10.0.0",
"commander": "^9.4.0",
"cosmiconfig": "^8.0.0",
"deepmerge": "^4.2.2",
"dot-prop": "^7.2.0",
@ -7230,12 +7231,12 @@
}
},
"node_modules/eslint-doc-generator/node_modules/commander": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.0.tgz",
"integrity": "sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA==",
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"engines": {
"node": ">=14"
"node": "^12.20.0 || >=14"
}
},
"node_modules/eslint-doc-generator/node_modules/cosmiconfig": {
@ -7260,9 +7261,9 @@
"dev": true
},
"node_modules/eslint-doc-generator/node_modules/type-fest": {
"version": "3.5.6",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.5.6.tgz",
"integrity": "sha512-6bd2bflx8ed7c99tc6zSTIzHr1/QG29bQoK4Qh8MYGnlPbODUzGxklLShjwc/xWQQFHgIci+y5Arv7Rbb0LjXw==",
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.5.2.tgz",
"integrity": "sha512-Ph7S4EhXzWy0sbljEuZo0tTNoLl+K2tPauGrQpcwUWrOVneLePTuhVzcuzVJJ6RU5DsNwQZka+8YtkXXU4z9cA==",
"dev": true,
"engines": {
"node": ">=14.16"
@ -7408,9 +7409,9 @@
"link": true
},
"node_modules/eslint-plugin-eslint-plugin": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/eslint-plugin-eslint-plugin/-/eslint-plugin-eslint-plugin-5.0.8.tgz",
"integrity": "sha512-bxPMZ3L/+5YypErWQMKUI9XdkLpgqOOO0CgbtHjk5Zxzcg4EVsWYPy8duvGSLxSyR60LBIoXNzVMueEZ3/j0AQ==",
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-eslint-plugin/-/eslint-plugin-eslint-plugin-5.0.7.tgz",
"integrity": "sha512-hcz4Bze1ECwv3Q/Bi/ZMZZNiuvI2YclNuxjnczkblQ0skrlPhdO83rSM7felf5n+7ZJOZi4GS8y8gNiRtvI0hA==",
"dev": true,
"dependencies": {
"eslint-utils": "^3.0.0",
@ -15196,9 +15197,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.0.tgz",
"integrity": "sha512-QHsz8GgQIGKlRi24yFc6a6lN69Idnx634w49ay6+jA5yFh7a1UY+4Rp6HPx/L/1zcEDPEij8cIsiqR6bQsE5VQ==",
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.4.tgz",
"integrity": "sha512-8o/QEhSSRb1a5i7TFR0iM4G16Z0vYB2OQVs4G3aAFXjn3T6yEx8AZxy1PgDF7I00LZHYA3WxaSYIf5e5sAX8Rw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"

View File

@ -54,8 +54,8 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
? String(mtpUser.photo.photoId)
: undefined;
const userType = buildApiUserType(mtpUser);
const usernames = buildApiUsernames(mtpUser);
const emojiStatus = mtpUser.emojiStatus ? buildApiUserEmojiStatus(mtpUser.emojiStatus) : undefined;
return {
id: buildApiPeerId(id, 'user'),
@ -74,7 +74,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
noStatus: !mtpUser.status,
...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }),
...(avatarHash && { avatarHash }),
...(mtpUser.emojiStatus && { emojiStatus: buildApiUserEmojiStatus(mtpUser.emojiStatus) }),
emojiStatus,
hasVideoAvatar,
...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }),
...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachBot: mtpUser.botAttachMenu }),

View File

@ -29,6 +29,7 @@ import {
import localDb from '../localDb';
import { pick } from '../../../util/iteratees';
import { deserializeBytes } from '../helpers';
import { DEFAULT_STATUS_ICON_ID } from '../../../config';
const CHANNEL_ID_MIN_LENGTH = 11; // Example: -1000000000
@ -580,3 +581,20 @@ export function buildInputChatReactions(chatReactions?: ApiChatReactions) {
return new GramJs.ChatReactionsNone();
}
export function buildInputEmojiStatus(emojiStatus: ApiSticker, expires?: number) {
if (emojiStatus.id === DEFAULT_STATUS_ICON_ID) {
return new GramJs.EmojiStatusEmpty();
}
if (expires) {
return new GramJs.EmojiStatusUntil({
documentId: BigInt(emojiStatus.id),
until: expires,
});
}
return new GramJs.EmojiStatus({
documentId: BigInt(emojiStatus.id),
});
}

View File

@ -35,15 +35,15 @@ export {
export {
fetchFullUser, fetchNearestCountry, fetchTopUsers, fetchContactList, fetchUsers,
updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam,
updateContact, importContact, deleteContact, fetchProfilePhotos, fetchCommonChats, reportSpam, updateEmojiStatus,
} from './users';
export {
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers,
fetchStickerSets, fetchRecentStickers, fetchFavoriteStickers, fetchFeaturedStickers, fetchRecentEmojiStatuses,
faveSticker, fetchStickers, fetchSavedGifs, saveGif, searchStickers, installStickerSet, uninstallStickerSet,
searchGifs, fetchAnimatedEmojis, fetchStickersForEmoji, fetchEmojiKeywords, fetchAnimatedEmojiEffects,
removeRecentSticker, clearRecentStickers, fetchCustomEmoji, fetchPremiumGifts, fetchCustomEmojiSets,
fetchFeaturedEmojiStickers, fetchGenericEmojiEffects, fetchDefaultTopicIcons,
fetchFeaturedEmojiStickers, fetchGenericEmojiEffects, fetchDefaultTopicIcons, fetchDefaultStatusEmojis,
} from './symbols';
export {

View File

@ -8,9 +8,10 @@ import { invokeRequest } from './client';
import {
buildStickerSet, buildStickerSetCovered, processStickerPackResult, processStickerResult,
} from '../apiBuilders/symbols';
import { buildApiUserEmojiStatus } from '../apiBuilders/users';
import { buildInputStickerSet, buildInputDocument, buildInputStickerSetShortName } from '../gramjsBuilders';
import { buildVideoFromDocument } from '../apiBuilders/messages';
import { DEFAULT_GIF_SEARCH_BOT_USERNAME, RECENT_STICKERS_LIMIT } from '../../../config';
import { DEFAULT_GIF_SEARCH_BOT_USERNAME, RECENT_STATUS_LIMIT, RECENT_STICKERS_LIMIT } from '../../../config';
import localDb from '../localDb';
@ -263,6 +264,21 @@ export async function fetchDefaultTopicIcons() {
};
}
export async function fetchDefaultStatusEmojis() {
const result = await invokeRequest(new GramJs.messages.GetStickerSet({
stickerset: new GramJs.InputStickerSetEmojiDefaultStatuses(),
}));
if (!(result instanceof GramJs.messages.StickerSet)) {
return undefined;
}
return {
set: buildStickerSet(result.set),
stickers: processStickerResult(result.documents),
};
}
export async function searchStickers({ query, hash = '0' }: { query: string; hash?: string }) {
const result = await invokeRequest(new GramJs.messages.SearchStickerSets({
q: query,
@ -418,6 +434,26 @@ export async function fetchEmojiKeywords({ language, fromVersion }: {
};
}
export async function fetchRecentEmojiStatuses(hash = '0') {
const result = await invokeRequest(new GramJs.account.GetRecentEmojiStatuses({ hash: BigInt(hash) }));
if (!result || result instanceof GramJs.account.EmojiStatusesNotModified) {
return undefined;
}
const documentIds = result.statuses
.slice(0, RECENT_STATUS_LIMIT)
.map(buildApiUserEmojiStatus)
.filter(Boolean)
.map(({ documentId }) => documentId);
const emojiStatuses = await fetchCustomEmoji({ documentId: documentIds });
return {
hash: String(result.hash),
emojiStatuses,
};
}
function processGifResult(gifs: GramJs.TypeDocument[]) {
return gifs
.map((document) => {

View File

@ -1,7 +1,7 @@
import BigInt from 'big-integer';
import { Api as GramJs } from '../../../lib/gramjs';
import type {
OnApiUpdate, ApiUser, ApiChat,
OnApiUpdate, ApiUser, ApiChat, ApiSticker,
} from '../../types';
import { COMMON_CHATS_LIMIT, PROFILE_PHOTOS_LIMIT } from '../../../config';
@ -13,6 +13,7 @@ import {
buildInputContact,
buildMtpPeerId,
getEntityTypeById,
buildInputEmojiStatus,
} from '../gramjsBuilders';
import { buildApiUser, buildApiUserFromFull, buildApiUsersAndStatuses } from '../apiBuilders/users';
import { buildApiChatFromPreview } from '../apiBuilders/chats';
@ -296,6 +297,12 @@ export function reportSpam(userOrChat: ApiUser | ApiChat) {
}), true);
}
export function updateEmojiStatus(emojiStatus: ApiSticker, expires?: number) {
return invokeRequest(new GramJs.account.UpdateEmojiStatus({
emojiStatus: buildInputEmojiStatus(emojiStatus, expires),
}), true);
}
function updateLocalDb(result: (GramJs.photos.Photos | GramJs.photos.PhotosSlice | GramJs.messages.Chats)) {
if ('chats' in result) {
addEntitiesWithPhotosToLocalDb(result.chats);

View File

@ -1104,6 +1104,8 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) {
chatId: buildApiPeerId(update.channelId, 'channel'),
order: update.order || [],
});
} else if (update instanceof GramJs.UpdateRecentEmojiStatuses) {
onUpdate({ '@type': 'updateRecentEmojiStatuses' });
} else if (DEBUG) {
const params = typeof update === 'object' && 'className' in update ? update.className : update;
log('UNEXPECTED UPDATE', params);

View File

@ -359,6 +359,10 @@ export type ApiUpdateUserEmojiStatus = {
emojiStatus?: ApiEmojiStatus;
};
export type ApiUpdateRecentEmojiStatuses = {
'@type': 'updateRecentEmojiStatuses';
};
export type ApiUpdateUserFullInfo = {
'@type': 'updateUserFullInfo';
id: string;
@ -624,7 +628,7 @@ export type ApiUpdate = (
ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState |
ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus |
ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic |
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics
ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses
);
export type OnApiUpdate = (update: ApiUpdate) => void;

View File

@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.13145 12.6908C7.13145 12.0004 7.69109 11.4408 8.38145 11.4408H14.947C15.4121 11.4408 15.8388 11.6991 16.0545 12.1112C16.2701 12.5233 16.2392 13.021 15.9741 13.4032L10.5563 21.214H14.18C14.8703 21.214 15.43 21.7736 15.43 22.464C15.43 23.1543 14.8703 23.714 14.18 23.714H8.16797C7.70284 23.714 7.27617 23.4557 7.06049 23.0436C6.8448 22.6315 6.87577 22.1337 7.14087 21.7516L12.5587 13.9408H8.38145C7.69109 13.9408 7.13145 13.3812 7.13145 12.6908Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.2134 9.53604C17.2134 8.84569 17.7731 8.28604 18.4634 8.28604H23.8321C24.2972 8.28604 24.7239 8.5443 24.9396 8.9564C25.1552 9.3685 25.1243 9.86628 24.8592 10.2485L20.4735 16.5712H23.3477C24.0381 16.5712 24.5977 17.1309 24.5977 17.8212C24.5977 18.5116 24.0381 19.0712 23.3477 19.0712H18.0852C17.6201 19.0712 17.1934 18.813 16.9777 18.4009C16.762 17.9888 16.793 17.491 17.0581 17.1088L21.4438 10.786H18.4634C17.7731 10.786 17.2134 10.2264 17.2134 9.53604Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -17,6 +17,7 @@ export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDi
export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal';
export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal';
export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal';
export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu';
export { default as AboutAdsModal } from '../components/common/AboutAdsModal';
export { default as CalendarModal } from '../components/common/CalendarModal';

View File

@ -108,7 +108,6 @@ const GroupCall: FC<OwnProps & StateProps> = ({
const isConnecting = connectionState !== 'connected';
const canSelfUnmute = meParticipant?.canSelfUnmute;
const shouldRaiseHand = !canSelfUnmute && meParticipant?.isMuted;
const handleOpenParticipantMenu = useCallback((anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => {
const rect = anchor.getBoundingClientRect();
const container = containerRef.current!;

View File

@ -13,7 +13,6 @@ import generateIdFor from '../../util/generateIdFor';
import useHeavyAnimationCheck from '../../hooks/useHeavyAnimationCheck';
import useBackgroundMode from '../../hooks/useBackgroundMode';
import useSyncEffect from '../../hooks/useSyncEffect';
import useAppLayout from '../../hooks/useAppLayout';
export type OwnProps = {
ref?: RefObject<HTMLDivElement>;
@ -89,8 +88,8 @@ const AnimatedSticker: FC<OwnProps> = ({
const containerId = useMemo(() => generateIdFor(ID_STORE, true), []);
const { isMobile } = useAppLayout();
const [animation, setAnimation] = useState<RLottieInstance>();
const animationRef = useRef<RLottieInstance>();
const wasPlaying = useRef(false);
const isFrozen = useRef(false);
const isFirstRender = useRef(true);
@ -140,7 +139,6 @@ const AnimatedSticker: FC<OwnProps> = ({
quality,
isLowPriority,
coords: sharedCanvasCoords,
isMobile,
},
color,
onEnded,
@ -152,6 +150,7 @@ const AnimatedSticker: FC<OwnProps> = ({
}
setAnimation(newAnimation);
animationRef.current = newAnimation;
};
if (RLottie) {
@ -167,7 +166,7 @@ const AnimatedSticker: FC<OwnProps> = ({
}
}, [
animation, animationId, tgsUrl, color, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop,
containerId, sharedCanvas, sharedCanvasCoords, isMobile,
containerId, sharedCanvas, sharedCanvasCoords,
]);
useEffect(() => {
@ -178,11 +177,12 @@ const AnimatedSticker: FC<OwnProps> = ({
useEffect(() => {
return () => {
if (animation) {
animation.removeContainer(containerId);
if (animationRef.current) {
animationRef.current.removeContainer(containerId);
animationRef.current = undefined;
}
};
}, [animation, containerId]);
}, [containerId]);
const playAnimation = useCallback((shouldRestart = false) => {
if (animation && (playRef.current || playSegmentRef.current)) {
@ -235,14 +235,11 @@ const AnimatedSticker: FC<OwnProps> = ({
}
}, [noLoop, animation]);
useSyncEffect(([prevSharedCanvasCoords, prevIsMobile]) => {
if (
(prevSharedCanvasCoords !== undefined && sharedCanvasCoords !== prevSharedCanvasCoords)
|| (prevIsMobile !== undefined && isMobile !== prevIsMobile)
) {
animation?.setSharedCanvasCoords(containerId, sharedCanvasCoords, isMobile);
useSyncEffect(([prevSharedCanvasCoords]) => {
if (prevSharedCanvasCoords !== undefined && sharedCanvasCoords !== prevSharedCanvasCoords) {
animation?.setSharedCanvasCoords(containerId, sharedCanvasCoords);
}
}, [sharedCanvasCoords, isMobile, containerId, animation]);
}, [sharedCanvasCoords, containerId, animation]);
useEffect(() => {
if (!animation) {

View File

@ -16,6 +16,13 @@
width: var(--custom-emoji-size);
height: var(--custom-emoji-size);
margin: 0.3125rem;
&.status-default {
font-size: 2rem;
padding: 0.25rem;
color: var(--color-text);
}
}
&.set-expand {
@ -117,13 +124,14 @@
opacity: 0;
}
}
.sticker-context-menu {
position: absolute;
cursor: default;
.sticker-context-menu {
position: absolute;
cursor: default;
z-index: var(--z-header-menu);
.bubble {
width: auto;
}
.bubble {
width: auto;
}
}

View File

@ -11,6 +11,7 @@ import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMe
import { IS_TOUCH_ENV } from '../../util/environment';
import { getPropertyHexColor } from '../../util/themeStyle';
import { hexToRgb } from '../../util/switchTheme';
import { getServerTimeOffset } from '../../util/serverTime';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
@ -33,6 +34,7 @@ type OwnProps<T> = {
className?: string;
noContextMenu?: boolean;
isSavedMessages?: boolean;
isStatusPicker?: boolean;
canViewSet?: boolean;
isCurrentUserPremium?: boolean;
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
@ -43,8 +45,19 @@ type OwnProps<T> = {
onFaveClick?: (sticker: ApiSticker) => void;
onUnfaveClick?: (sticker: ApiSticker) => void;
onRemoveRecentClick?: (sticker: ApiSticker) => void;
onContextMenuOpen?: NoneToVoidFunction;
onContextMenuClose?: NoneToVoidFunction;
onContextMenuClick?: NoneToVoidFunction;
};
const contentForStatusMenuContext = [
{ title: 'SetTimeoutFor.Hours', value: 1, arg: 3600 },
{ title: 'SetTimeoutFor.Hours', value: 2, arg: 7200 },
{ title: 'SetTimeoutFor.Hours', value: 8, arg: 28800 },
{ title: 'SetTimeoutFor.Days', value: 1, arg: 86400 },
{ title: 'SetTimeoutFor.Days', value: 2, arg: 172800 },
];
const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult | undefined = undefined>({
sticker,
size,
@ -53,6 +66,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
className,
noContextMenu,
isSavedMessages,
isStatusPicker,
canViewSet,
observeIntersection,
isCurrentUserPremium,
@ -63,10 +77,15 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
onFaveClick,
onUnfaveClick,
onRemoveRecentClick,
onContextMenuOpen,
onContextMenuClose,
onContextMenuClick,
}: OwnProps<T>) => {
const { openStickerSet, openPremiumModal } = getActions();
const { openStickerSet, openPremiumModal, setEmojiStatus } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const menuRef = useRef<HTMLDivElement>(null);
const lang = useLang();
const [customColor, setCustomColor] = useState<[number, number, number] | undefined>();
const hasCustomColor = sticker.shouldUseTextColor;
@ -99,6 +118,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
handleBeforeContextMenu, handleContextMenu,
handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(ref);
const shouldRenderContextMenu = Boolean(!noContextMenu && contextMenuPosition);
const getTriggerElement = useCallback(() => ref.current, []);
@ -108,10 +128,14 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
);
const getMenuElement = useCallback(
() => ref.current!.querySelector('.sticker-context-menu .bubble'),
[],
() => {
return isStatusPicker ? menuRef.current : ref.current!.querySelector('.sticker-context-menu .bubble');
},
[isStatusPicker],
);
const getLayout = () => ({ withPortal: isStatusPicker });
const {
positionX, positionY, transformOriginX, transformOriginY, style: menuStyle,
} = useContextMenuPosition(
@ -119,8 +143,17 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
getTriggerElement,
getRootElement,
getMenuElement,
getLayout,
);
useEffect(() => {
if (isContextMenuOpen) {
onContextMenuOpen?.();
} else {
onContextMenuClose?.();
}
}, [isContextMenuOpen, onContextMenuClose, onContextMenuOpen]);
useEffect(() => {
if (!isIntersecting) handleContextMenuClose();
}, [handleContextMenuClose, isIntersecting]);
@ -170,6 +203,18 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
openStickerSet({ stickerSetInfo });
}, [openStickerSet, stickerSetInfo]);
const handleEmojiStatusExpiresClick = useCallback((e: React.SyntheticEvent, duration = 0) => {
e.preventDefault();
e.stopPropagation();
handleContextMenuClose();
onContextMenuClick?.();
setEmojiStatus({
emojiStatus: sticker,
expires: Date.now() / 1000 + duration + getServerTimeOffset(),
});
}, [setEmojiStatus, sticker, handleContextMenuClose, onContextMenuClick]);
const shouldShowCloseButton = !IS_TOUCH_ENV && onRemoveRecentClick;
const fullClassName = buildClassName(
@ -181,10 +226,22 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
);
const contextMenuItems = useMemo(() => {
if (noContextMenu || isCustomEmoji) return [];
if (!shouldRenderContextMenu || noContextMenu || (isCustomEmoji && !isStatusPicker)) return [];
const items: ReactNode[] = [];
if (isCustomEmoji) {
contentForStatusMenuContext.forEach((item) => {
items.push(
<MenuItem onClick={handleEmojiStatusExpiresClick} clickArg={item.arg}>
{lang(item.title, item.value, 'i')}
</MenuItem>,
);
});
return items;
}
if (onUnfaveClick) {
items.push(
<MenuItem icon="favorite" onClick={handleContextUnfave}>
@ -228,9 +285,9 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
}
return items;
}, [
canViewSet, handleContextFave, handleContextRemoveRecent, handleContextUnfave, handleOpenSet, handleSendQuiet,
handleSendScheduled, isLocked, isSavedMessages, lang, onFaveClick, onRemoveRecentClick, onUnfaveClick, onClick,
noContextMenu, isCustomEmoji,
shouldRenderContextMenu, noContextMenu, isCustomEmoji, isStatusPicker, onUnfaveClick, onFaveClick, isLocked,
onClick, canViewSet, onRemoveRecentClick, handleEmojiStatusExpiresClick, lang, handleContextUnfave,
handleContextFave, isSavedMessages, handleSendScheduled, handleSendQuiet, handleOpenSet, handleContextRemoveRecent,
]);
return (
@ -278,8 +335,9 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
<i className="icon-close" />
</Button>
)}
{Boolean(contextMenuItems.length) && contextMenuPosition !== undefined && (
{Boolean(contextMenuItems.length) && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
transformOriginX={transformOriginX}
transformOriginY={transformOriginY}
@ -288,6 +346,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
style={menuStyle}
className="sticker-context-menu"
autoClose
withPortal={isStatusPicker}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
>

View File

@ -115,7 +115,7 @@ const StickerView: FC<OwnProps> = ({
const noTransition = isLottie && preloadedPreviewData;
const bounds = useBoundsInSharedCanvas(containerRef, sharedCanvasRef);
const realSize = bounds.size || size;
const realSize = size || bounds.size;
// Preload preview for Message Input and local message
useMedia(previewMediaHash, !shouldLoad || !shouldPreloadPreview, undefined, cacheBuster);

View File

@ -90,10 +90,26 @@
@include overflow-y-overlay();
}
.passcode-lock {
.extra-spacing {
position: relative;
margin-left: 0.8125rem;
}
.emoji-status-effect {
top: 50%;
left: 50%;
}
.emoji-status {
--custom-emoji-size: 1.5rem;
--color-text: var(--color-primary);
}
.PremiumIcon {
width: 1.5rem;
height: 1.5rem;
}
// @optimization
@include while-transition() {
.Menu .bubble {

View File

@ -25,7 +25,9 @@ import { formatDateToString } from '../../../util/dateFormat';
import switchTheme from '../../../util/switchTheme';
import { setPermanentWebVersion } from '../../../util/permanentWebVersion';
import { clearWebsync } from '../../../util/websync';
import { selectCurrentMessageList, selectTabState, selectTheme } from '../../../global/selectors';
import {
selectCurrentMessageList, selectIsCurrentUserPremium, selectTabState, selectTheme,
} from '../../../global/selectors';
import { isChatArchived } from '../../../global/helpers';
import useLang from '../../../hooks/useLang';
import useConnectionStatus from '../../../hooks/useConnectionStatus';
@ -43,6 +45,7 @@ import PickerSelectedItem from '../../common/PickerSelectedItem';
import Switcher from '../../ui/Switcher';
import ShowTransition from '../../ui/ShowTransition';
import ConnectionStatusOverlay from '../ConnectionStatusOverlay';
import StatusButton from './StatusButton';
import './LeftMainHeader.scss';
@ -70,6 +73,7 @@ type StateProps =
animationLevel: AnimationLevel;
chatsById?: Record<string, ApiChat>;
isMessageListOpen: boolean;
isCurrentUserPremium?: boolean;
isConnectionStatusMinimized: ISettings['isConnectionStatusMinimized'];
areChatsLoaded?: boolean;
hasPasscode?: boolean;
@ -92,6 +96,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
onReset,
searchQuery,
isLoading,
isCurrentUserPremium,
shouldSkipTransition,
currentUserId,
globalSearchChatId,
@ -430,6 +435,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
/>
)}
</SearchInput>
{isCurrentUserPremium && <StatusButton />}
{hasPasscode && (
<Button
round
@ -438,7 +444,7 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
color="translucent"
ariaLabel={`${lang('ShortcutsController.Others.LockByPasscode')} (Ctrl+Shift+L)`}
onClick={handleLockScreen}
className="passcode-lock"
className={buildClassName(!isCurrentUserPremium && 'extra-spacing')}
>
<i className="icon-lock" />
</Button>
@ -482,6 +488,7 @@ export default memo(withGlobal<OwnProps>(
isSyncing,
isMessageListOpen: Boolean(selectCurrentMessageList(global)),
isConnectionStatusMinimized,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
areChatsLoaded: Boolean(global.chats.listIds.active),
hasPasscode: Boolean(global.passcode.hasPasscode),
canInstall: Boolean(tabState.canInstall),

View File

@ -0,0 +1,103 @@
import React, { memo, useCallback, useRef } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ApiEmojiStatus, ApiSticker } from '../../../api/types';
import { EMOJI_STATUS_LOOP_LIMIT } from '../../../config';
import { selectUser } from '../../../global/selectors';
import useFlag from '../../../hooks/useFlag';
import useAppLayout from '../../../hooks/useAppLayout';
import useTimeout from '../../../hooks/useTimeout';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import { getServerTimeOffset } from '../../../util/serverTime';
import Button from '../../ui/Button';
import CustomEmoji from '../../common/CustomEmoji';
import PremiumIcon from '../../common/PremiumIcon';
import CustomEmojiEffect from '../../middle/message/CustomEmojiEffect';
import StatusPickerMenu from './StatusPickerMenu.async';
interface StateProps {
emojiStatus?: ApiEmojiStatus;
}
const EFFECT_DURATION_MS = 1500;
const EMOJI_STATUS_SIZE = 24;
const StatusButton: FC<StateProps> = ({ emojiStatus }) => {
const { setEmojiStatus, loadCurrentUser } = getActions();
// eslint-disable-next-line no-null/no-null
const buttonRef = useRef<HTMLButtonElement>(null);
const [shouldShowEffect, markShouldShowEffect, unmarkShouldShowEffect] = useFlag(false);
const [isEffectShown, showEffect, hideEffect] = useFlag(false);
const [isStatusPickerOpen, openStatusPicker, closeStatusPicker] = useFlag(false);
const { isMobile } = useAppLayout();
const delay = emojiStatus?.until ? emojiStatus.until * 1000 - Date.now() + getServerTimeOffset() * 1000 : undefined;
useTimeout(loadCurrentUser, delay);
useEffectWithPrevDeps(([prevEmojiStatus]) => {
if (shouldShowEffect && emojiStatus && prevEmojiStatus && emojiStatus.documentId !== prevEmojiStatus.documentId) {
showEffect();
unmarkShouldShowEffect();
}
}, [emojiStatus, shouldShowEffect, showEffect, unmarkShouldShowEffect] as const);
const handleEmojiStatusSet = useCallback((sticker: ApiSticker) => {
markShouldShowEffect();
setEmojiStatus({ emojiStatus: sticker });
}, [markShouldShowEffect, setEmojiStatus]);
useTimeout(hideEffect, isEffectShown ? EFFECT_DURATION_MS : undefined);
const handleEmojiStatusClick = useCallback(() => {
openStatusPicker();
}, [openStatusPicker]);
return (
<div className="extra-spacing">
{Boolean(isEffectShown && emojiStatus) && (
<CustomEmojiEffect
reaction={emojiStatus!}
isLottie
className="emoji-status-effect"
/>
)}
<Button
round
ref={buttonRef}
ripple={!isMobile}
size="smaller"
color="translucent"
className="emoji-status"
onClick={handleEmojiStatusClick}
>
{emojiStatus ? (
<CustomEmoji
key={emojiStatus.documentId}
documentId={emojiStatus.documentId}
size={EMOJI_STATUS_SIZE}
loopLimit={EMOJI_STATUS_LOOP_LIMIT}
/>
) : <PremiumIcon />}
</Button>
<StatusPickerMenu
statusButtonRef={buttonRef}
isOpen={isStatusPickerOpen}
onEmojiStatusSelect={handleEmojiStatusSet}
onClose={closeStatusPicker}
/>
</div>
);
};
export default memo(withGlobal((global) => {
const { currentUserId } = global;
const currentUser = currentUserId ? selectUser(global, currentUserId) : undefined;
return {
emojiStatus: currentUser?.emojiStatus,
};
})(StatusButton));

View File

@ -0,0 +1,18 @@
import React, { memo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { OwnProps } from './StatusPickerMenu';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const StatusPickerMenuAsync: FC<OwnProps> = (props) => {
const { isOpen } = props;
const StatusPickerMenu = useModuleLoader(Bundles.Extra, 'StatusPickerMenu', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return StatusPickerMenu ? <StatusPickerMenu {...props} /> : undefined;
};
export default memo(StatusPickerMenuAsync);

View File

@ -0,0 +1,17 @@
.menuContent {
--offset-y: 3.25rem !important;
--offset-x: auto !important;
--color-text: var(--color-primary);
left: 0.5rem;
width: 100%;
max-width: 26rem;
height: 26rem;
padding: 0 !important;
@media (max-width: 26rem) {
left: 0.5rem !important;
right: 0.5rem !important;
width: calc(100vw - 1rem);
}
}

View File

@ -0,0 +1,84 @@
import type { RefObject } from 'react';
import React, {
useCallback, memo, 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 useFlag from '../../../hooks/useFlag';
import Menu from '../../ui/Menu';
import Portal from '../../ui/Portal';
import CustomEmojiPicker from '../../middle/composer/CustomEmojiPicker';
import styles from './StatusPickerMenu.module.scss';
export type OwnProps = {
isOpen: boolean;
statusButtonRef: RefObject<HTMLButtonElement>;
onEmojiStatusSelect: (emojiStatus: ApiSticker) => void;
onClose: () => void;
};
interface StateProps {
areFeaturedStickersLoaded?: boolean;
}
const StatusPickerMenu: FC<OwnProps & StateProps> = ({
isOpen,
statusButtonRef,
areFeaturedStickersLoaded,
onEmojiStatusSelect,
onClose,
}) => {
const { loadFeaturedEmojiStickers } = getActions();
const transformOriginX = useRef<number>();
const [isContextMenuShown, markContextMenuShown, unmarkContextMenuShown] = useFlag();
useEffect(() => {
transformOriginX.current = statusButtonRef.current!.getBoundingClientRect().right;
}, [isOpen, statusButtonRef]);
useEffect(() => {
if (isOpen && !areFeaturedStickersLoaded) {
loadFeaturedEmojiStickers();
}
}, [areFeaturedStickersLoaded, isOpen, loadFeaturedEmojiStickers]);
const handleEmojiSelect = useCallback((sticker: ApiSticker) => {
onEmojiStatusSelect(sticker);
onClose();
}, [onClose, onEmojiStatusSelect]);
return (
<Portal>
<Menu
isOpen={isOpen}
noCompact
positionX="right"
bubbleClassName={styles.menuContent}
onClose={onClose}
transformOriginX={transformOriginX.current}
noCloseOnBackdrop={isContextMenuShown}
>
<CustomEmojiPicker
idPrefix="status-emoji-set-"
loadAndPlay={isOpen}
isStatusPicker
onContextMenuOpen={markContextMenuShown}
onContextMenuClose={unmarkContextMenuShown}
onCustomEmojiSelect={handleEmojiSelect}
onContextMenuClick={onClose}
/>
</Menu>
</Portal>
);
};
export default memo(withGlobal<OwnProps>((global): StateProps => {
return {
areFeaturedStickersLoaded: Boolean(global.customEmojis.featuredIds?.length),
};
})(StatusPickerMenu));

View File

@ -20,6 +20,7 @@ import {
selectChatMessage,
selectTabState,
selectCurrentMessageList,
selectIsCurrentUserPremium,
selectIsForwardModalOpen,
selectIsMediaViewerOpen,
selectIsRightColumnShown,
@ -128,6 +129,7 @@ type StateProps = {
deleteFolderDialogId?: number;
isPaymentModalOpen?: boolean;
isReceiptModalOpen?: boolean;
isCurrentUserPremium?: boolean;
};
const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
@ -176,6 +178,7 @@ const Main: FC<OwnProps & StateProps> = ({
isPremiumModalOpen,
isPaymentModalOpen,
isReceiptModalOpen,
isCurrentUserPremium,
deleteFolderDialogId,
isMasterTab,
}) => {
@ -194,6 +197,7 @@ const Main: FC<OwnProps & StateProps> = ({
loadDefaultTopicIcons,
loadAddedStickers,
loadFavoriteStickers,
loadDefaultStatusIcons,
ensureTimeFormat,
closeStickerSetModal,
closeCustomEmojiSets,
@ -209,6 +213,7 @@ const Main: FC<OwnProps & StateProps> = ({
checkAppVersion,
openChat,
toggleLeftColumn,
loadRecentEmojiStatuses,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -245,12 +250,17 @@ const Main: FC<OwnProps & StateProps> = ({
loadContactList();
loadPremiumGifts();
loadDefaultTopicIcons();
loadDefaultStatusIcons();
checkAppVersion();
if (isCurrentUserPremium) {
loadRecentEmojiStatuses();
}
}
}, [
lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings,
loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachBots, loadContactList,
loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons, isMasterTab,
loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons,
loadDefaultStatusIcons, loadRecentEmojiStatuses, isCurrentUserPremium, isMasterTab,
]);
// Language-based API calls
@ -553,6 +563,7 @@ export default memo(withGlobal<OwnProps>(
webApp,
currentUser,
urlAuth,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isPremiumModalOpen: premiumModal?.isOpen,
limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen,

View File

@ -34,6 +34,7 @@ export const PREMIUM_FEATURE_TITLES: Record<string, string> = {
more_upload: 'PremiumPreviewUploads',
advanced_chat_management: 'PremiumPreviewAdvancedChatManagement',
animated_userpics: 'PremiumPreviewAnimatedProfiles',
emoji_status: 'PremiumPreviewEmojiStatus',
};
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
@ -48,6 +49,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
more_upload: 'PremiumPreviewUploadsDescription',
advanced_chat_management: 'PremiumPreviewAdvancedChatManagementDescription',
animated_userpics: 'PremiumPreviewAnimatedProfilesDescription',
emoji_status: 'PremiumPreviewEmojiStatusDescription',
};
export const PREMIUM_FEATURE_SECTIONS = [
@ -62,6 +64,7 @@ export const PREMIUM_FEATURE_SECTIONS = [
'advanced_chat_management',
'profile_badge',
'animated_userpics',
'emoji_status',
];
const PREMIUM_BOTTOM_VIDEOS: string[] = [
@ -70,6 +73,7 @@ const PREMIUM_BOTTOM_VIDEOS: string[] = [
'advanced_chat_management',
'profile_badge',
'animated_userpics',
'emoji_status',
];
type ApiLimitTypeWithoutUpload = Exclude<ApiLimitType, 'uploadMaxFileparts'>;

View File

@ -1,5 +1,5 @@
import React, {
memo, useCallback, useEffect, useRef, useState,
memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
@ -41,6 +41,7 @@ import PremiumChats from '../../../assets/premium/PremiumChats.svg';
import PremiumBadge from '../../../assets/premium/PremiumBadge.svg';
import PremiumVideo from '../../../assets/premium/PremiumVideo.svg';
import PremiumEmoji from '../../../assets/premium/PremiumEmoji.svg';
import PremiumStatus from '../../../assets/premium/PremiumStatus.svg';
import styles from './PremiumMainModal.module.scss';
@ -58,6 +59,7 @@ const PREMIUM_FEATURE_COLOR_ICONS: Record<string, string> = {
more_upload: PremiumFile,
advanced_chat_management: PremiumChats,
animated_userpics: PremiumVideo,
emoji_status: PremiumStatus,
};
export type OwnProps = {
@ -176,6 +178,11 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
showConfetti();
}, [isPremium, showConfetti]);
const filteredSections = useMemo(() => {
if (!premiumPromoOrder) return PREMIUM_FEATURE_SECTIONS;
return premiumPromoOrder.filter((section) => PREMIUM_FEATURE_SECTIONS.includes(section));
}, [premiumPromoOrder]);
if (!promo) return undefined;
// TODO Support all subscription options
@ -254,8 +261,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
</div>
<div className={buildClassName(styles.list, isPremium && styles.noButton)}>
{(premiumPromoOrder || PREMIUM_FEATURE_SECTIONS).map((section, index) => {
if (!PREMIUM_FEATURE_SECTIONS.includes(section)) return undefined;
{filteredSections.map((section, index) => {
return (
<PremiumFeatureItem
key={section}

View File

@ -12,7 +12,8 @@ import {
FAVORITE_SYMBOL_SET_ID,
PREMIUM_STICKER_SET_ID,
RECENT_SYMBOL_SET_ID,
SLIDE_TRANSITION_DURATION, STICKER_PICKER_MAX_SHARED_COVERS,
SLIDE_TRANSITION_DURATION,
STICKER_PICKER_MAX_SHARED_COVERS,
STICKER_SIZE_PICKER_HEADER,
} from '../../../config';
import { IS_TOUCH_ENV } from '../../../util/environment';
@ -44,16 +45,23 @@ type OwnProps = {
chatId?: string;
className?: string;
loadAndPlay: boolean;
isStatusPicker?: boolean;
idPrefix?: string;
withDefaultTopicIcons?: boolean;
onCustomEmojiSelect: (sticker: ApiSticker) => void;
onContextMenuOpen?: NoneToVoidFunction;
onContextMenuClose?: NoneToVoidFunction;
onContextMenuClick?: NoneToVoidFunction;
};
type StateProps = {
customEmojisById?: Record<string, ApiSticker>;
recentCustomEmojiIds?: string[];
recentStatusEmojis?: ApiSticker[];
stickerSetsById: Record<string, ApiStickerSet>;
addedCustomEmojiIds?: string[];
customEmojisById: Record<string, ApiSticker>;
recentCustomEmojiIds: string[];
defaultTopicIconsId?: string;
defaultStatusIconsId?: string;
customEmojiFeaturedIds?: string[];
canAnimate?: boolean;
isSavedMessages?: boolean;
@ -63,6 +71,7 @@ type StateProps = {
const SMOOTH_SCROLL_DISTANCE = 500;
const HEADER_BUTTON_WIDTH = 52; // px (including margin)
const STICKER_INTERSECTION_THROTTLE = 200;
const DEFAULT_ID_PREFIX = 'custom-emoji-set';
const stickerSetIntersections: boolean[] = [];
@ -72,14 +81,21 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
addedCustomEmojiIds,
customEmojisById,
recentCustomEmojiIds,
recentStatusEmojis,
stickerSetsById,
idPrefix = DEFAULT_ID_PREFIX,
customEmojiFeaturedIds,
canAnimate,
isStatusPicker,
isSavedMessages,
isCurrentUserPremium,
withDefaultTopicIcons,
defaultTopicIconsId,
defaultStatusIconsId,
onCustomEmojiSelect,
onContextMenuOpen,
onContextMenuClose,
onContextMenuClick,
}) => {
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
@ -92,17 +108,23 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
const [activeSetIndex, setActiveSetIndex] = useState<number>(0);
const recentCustomEmojis = useMemo(() => {
return isStatusPicker
? recentStatusEmojis
: Object.values(pickTruthy(customEmojisById!, recentCustomEmojiIds!));
}, [customEmojisById, isStatusPicker, recentCustomEmojiIds, recentStatusEmojis]);
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-')) {
if (!id || !id.startsWith(idPrefix)) {
return;
}
const index = Number(id.replace('custom-emoji-set-', ''));
const index = Number(id.replace(`${idPrefix}-`, ''));
stickerSetIntersections[index] = entry.isIntersecting;
});
@ -122,10 +144,6 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
const areAddedLoaded = Boolean(addedCustomEmojiIds);
const recentCustomEmojis = useMemo(() => {
return Object.values(pickTruthy(customEmojisById, recentCustomEmojiIds));
}, [customEmojisById, recentCustomEmojiIds]);
const allSets = useMemo(() => {
if (!addedCustomEmojiIds) {
return MEMO_EMPTY_ARRAY;
@ -133,7 +151,19 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
const defaultSets: StickerSetOrRecent[] = [];
if (withDefaultTopicIcons) {
if (isStatusPicker) {
const defaultStatusIconsPack = stickerSetsById[defaultStatusIconsId!];
if (defaultStatusIconsPack.stickers?.length) {
const stickers = (defaultStatusIconsPack.stickers || []).concat(recentCustomEmojis || []);
defaultSets.push({
...defaultStatusIconsPack,
stickers,
count: stickers.length,
id: RECENT_SYMBOL_SET_ID,
title: lang('RecentStickers'),
});
}
} else if (withDefaultTopicIcons) {
const defaultTopicIconsPack = stickerSetsById[defaultTopicIconsId!];
if (defaultTopicIconsPack.stickers?.length) {
defaultSets.push({
@ -142,7 +172,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
title: lang('RecentStickers'),
});
}
} else if (recentCustomEmojis.length) {
} else if (recentCustomEmojis?.length) {
defaultSets.push({
id: RECENT_SYMBOL_SET_ID,
accessHash: '0',
@ -162,8 +192,8 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
...setsToDisplay,
];
}, [
addedCustomEmojiIds, defaultTopicIconsId, customEmojiFeaturedIds, lang, recentCustomEmojis, stickerSetsById,
withDefaultTopicIcons,
addedCustomEmojiIds, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis,
customEmojiFeaturedIds, stickerSetsById, defaultStatusIconsId, lang, defaultTopicIconsId,
]);
const noPopulatedSets = useMemo(() => (
@ -194,9 +224,9 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
const selectStickerSet = useCallback((index: number) => {
setActiveSetIndex(index);
const stickerSetEl = document.getElementById(`custom-emoji-set-${index}`)!;
const stickerSetEl = document.getElementById(`${idPrefix}-${index}`)!;
fastSmoothScroll(containerRef.current!, stickerSetEl, 'start', undefined, SMOOTH_SCROLL_DISTANCE);
}, []);
}, [idPrefix]);
const handleEmojiSelect = useCallback((emoji: ApiSticker) => {
onCustomEmojiSelect(emoji);
@ -298,14 +328,19 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
stickerSet={stickerSet}
loadAndPlay={Boolean(canAnimate && loadAndPlay)}
index={i}
idPrefix={idPrefix}
observeIntersection={observeIntersection}
shouldRender={activeSetIndex >= i - 1 && activeSetIndex <= i + 1}
isSavedMessages={isSavedMessages}
shouldHideRecentHeader={withDefaultTopicIcons}
isStatusPicker={isStatusPicker}
shouldHideRecentHeader={withDefaultTopicIcons || isStatusPicker}
withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID}
isCustomEmojiPicker
withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID}
isCurrentUserPremium={isCurrentUserPremium}
onStickerSelect={handleEmojiSelect}
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
onContextMenuClick={onContextMenuClick}
/>
))}
</div>
@ -314,7 +349,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
(global, { chatId, isStatusPicker }): StateProps => {
const {
stickers: {
setsById: stickerSetsById,
@ -322,6 +357,9 @@ export default memo(withGlobal<OwnProps>(
customEmojis: {
byId: customEmojisById,
featuredIds: customEmojiFeaturedIds,
statusRecent: {
emojis: recentStatusEmojis,
},
},
recentCustomEmojis: recentCustomEmojiIds,
} = global;
@ -329,15 +367,17 @@ export default memo(withGlobal<OwnProps>(
const isSavedMessages = Boolean(chatId && selectIsChatWithSelf(global, chatId));
return {
customEmojisById: !isStatusPicker ? customEmojisById : undefined,
recentCustomEmojiIds: !isStatusPicker ? recentCustomEmojiIds : undefined,
recentStatusEmojis: isStatusPicker ? recentStatusEmojis : undefined,
stickerSetsById,
addedCustomEmojiIds: global.customEmojis.added.setIds,
canAnimate: global.settings.byKey.shouldLoopStickers,
isSavedMessages,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
customEmojisById,
recentCustomEmojiIds,
customEmojiFeaturedIds,
defaultTopicIconsId: global.defaultTopicIconsId,
defaultStatusIconsId: global.defaultStatusIconsId,
};
},
)(CustomEmojiPicker));

View File

@ -98,4 +98,12 @@
margin: 0 0.25rem;
border-radius: var(--border-radius-messages-small);
}
.picker-disabled {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@ -9,6 +9,7 @@ import type { StickerSetOrRecent } from '../../../types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import {
DEFAULT_STATUS_ICON_ID,
DEFAULT_TOPIC_ICON_STICKER_ID,
EMOJI_SIZE_PICKER, FAVORITE_SYMBOL_SET_ID, RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER,
} from '../../../config';
@ -32,18 +33,23 @@ type OwnProps = {
stickerSet: StickerSetOrRecent;
loadAndPlay: boolean;
index: number;
idPrefix?: string;
shouldRender: boolean;
favoriteStickers?: ApiSticker[];
isSavedMessages?: boolean;
isStatusPicker?: boolean;
isCurrentUserPremium?: boolean;
isCustomEmojiPicker?: boolean;
shouldHideRecentHeader?: boolean;
withDefaultTopicIcon?: boolean;
withDefaultStatusIcon?: boolean;
observeIntersection: ObserveFn;
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onStickerUnfave?: (sticker: ApiSticker) => void;
onStickerFave?: (sticker: ApiSticker) => void;
onStickerRemoveRecent?: (sticker: ApiSticker) => void;
onContextMenuOpen?: NoneToVoidFunction;
onContextMenuClose?: NoneToVoidFunction;
onContextMenuClick?: NoneToVoidFunction;
};
const ITEMS_PER_ROW_FALLBACK = 8;
@ -52,18 +58,23 @@ const StickerSet: FC<OwnProps> = ({
stickerSet,
loadAndPlay,
index,
idPrefix,
shouldRender,
favoriteStickers,
isSavedMessages,
isStatusPicker,
isCurrentUserPremium,
isCustomEmojiPicker,
shouldHideRecentHeader,
withDefaultTopicIcon,
withDefaultStatusIcon,
observeIntersection,
onStickerSelect,
onStickerUnfave,
onStickerFave,
onStickerRemoveRecent,
onContextMenuOpen,
onContextMenuClose,
onContextMenuClick,
}) => {
const {
clearRecentStickers,
@ -93,7 +104,7 @@ const StickerSet: FC<OwnProps> = ({
const stickerMarginPx = isMobile ? 8 : 16;
const emojiMarginPx = isMobile ? 8 : 10;
const containerPaddingPx = isMobile ? 8 : 0;
const containerPaddingPx = isMobile && !isStatusPicker ? 8 : 0;
const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID;
const isFavorite = stickerSet.id === FAVORITE_SYMBOL_SET_ID;
const isEmoji = stickerSet.isEmoji;
@ -131,6 +142,17 @@ const StickerSet: FC<OwnProps> = ({
} satisfies ApiSticker);
}, [onStickerSelect]);
const handleDefaultStatusIconClick = useCallback(() => {
onStickerSelect?.({
id: DEFAULT_STATUS_ICON_ID,
isLottie: false,
isVideo: false,
stickerSetInfo: {
shortName: 'dummy',
},
} satisfies ApiSticker);
}, [onStickerSelect]);
const itemSize = isEmoji ? EMOJI_SIZE_PICKER : STICKER_SIZE_PICKER;
const margin = isEmoji ? emojiMarginPx : stickerMarginPx;
@ -183,7 +205,7 @@ const StickerSet: FC<OwnProps> = ({
<div
ref={ref}
key={stickerSet.id}
id={`${isCustomEmojiPicker ? 'custom-emoji-set' : 'sticker-set'}-${index}`}
id={`${idPrefix || 'sticker-set'}-${index}`}
className={
buildClassName('symbol-set', isLocked && 'symbol-set-locked')
}
@ -231,6 +253,16 @@ const StickerSet: FC<OwnProps> = ({
<img src={grey} alt="Reset" />
</Button>
)}
{withDefaultStatusIcon && (
<Button
className="StickerButton custom-emoji status-default"
color="translucent"
onClick={handleDefaultStatusIconClick}
key="default-status-icon"
>
<i className="icon-premium" />
</Button>
)}
{shouldRender && stickerSet.stickers && stickerSet.stickers
.slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length)
.map((sticker, i) => {
@ -248,6 +280,7 @@ const StickerSet: FC<OwnProps> = ({
observeIntersection={observeIntersection}
noAnimate={!loadAndPlay}
isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker}
canViewSet
isCurrentUserPremium={isCurrentUserPremium}
sharedCanvasRef={canvasRef}
@ -256,6 +289,9 @@ const StickerSet: FC<OwnProps> = ({
onUnfaveClick={isFavorite && favoriteStickerIdsSet?.has(sticker.id) ? onStickerUnfave : undefined}
onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined}
onRemoveRecentClick={isRecent ? onStickerRemoveRecent : undefined}
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
onContextMenuClick={onContextMenuClick}
/>
);
})}

View File

@ -20,15 +20,16 @@ import { REM } from '../../../common/helpers/mediaDimensions';
import useResizeObserver from '../../../../hooks/useResizeObserver';
import useBackgroundMode from '../../../../hooks/useBackgroundMode';
import useAppLayout from '../../../../hooks/useAppLayout';
import useThrottledCallback from '../../../../hooks/useThrottledCallback';
const SIZE = 1.25 * REM;
const THROTTLE_MS = 300;
type CustomEmojiPlayer = {
play: () => void;
pause: () => void;
destroy: () => void;
updatePosition: (x: number, y: number, isMobile?: boolean) => void;
updatePosition: (x: number, y: number) => void;
};
export default function useInputCustomEmojis(
@ -42,8 +43,6 @@ export default function useInputCustomEmojis(
) {
const mapRef = useRef<Map<string, CustomEmojiPlayer>>(new Map());
const { isMobile } = useAppLayout();
const removeContainers = useCallback((ids: string[]) => {
ids.forEach((id) => {
const player = mapRef.current.get(id);
@ -80,7 +79,7 @@ export default function useInputCustomEmojis(
if (mapRef.current.has(id)) {
const player = mapRef.current.get(id)!;
player.updatePosition(x, y, isMobile);
player.updatePosition(x, y);
return;
}
@ -99,7 +98,6 @@ export default function useInputCustomEmojis(
mediaUrl,
isHq,
position: { x, y },
isMobile,
});
animation.play();
@ -107,10 +105,7 @@ export default function useInputCustomEmojis(
});
removeContainers(Array.from(removedContainers));
}, [
absoluteContainerRef, inputRef, prefixId, isMobile, removeContainers, sharedCanvasHqRef,
sharedCanvasRef,
]);
}, [absoluteContainerRef, inputRef, prefixId, removeContainers, sharedCanvasHqRef, sharedCanvasRef]);
useEffect(() => {
addCustomEmojiInputRenderCallback(synchronizeElements);
@ -129,7 +124,13 @@ export default function useInputCustomEmojis(
synchronizeElements();
}, [getHtml, synchronizeElements, inputRef, removeContainers, sharedCanvasRef, isActive]);
useResizeObserver(sharedCanvasRef, synchronizeElements, true);
const throttledSynchronizeElements = useThrottledCallback(
synchronizeElements,
[synchronizeElements],
THROTTLE_MS,
false,
);
useResizeObserver(sharedCanvasRef, throttledSynchronizeElements);
const freezeAnimation = useCallback(() => {
mapRef.current.forEach((player) => {
@ -162,7 +163,6 @@ function createPlayer({
mediaUrl,
position,
isHq,
isMobile,
}: {
customEmoji: ApiSticker;
sharedCanvasRef: React.RefObject<HTMLCanvasElement>;
@ -172,7 +172,6 @@ function createPlayer({
mediaUrl: string;
position: { x: number; y: number };
isHq?: boolean;
isMobile?: boolean;
}): CustomEmojiPlayer {
if (customEmoji.isLottie) {
const lottie = RLottie.init(
@ -185,15 +184,14 @@ function createPlayer({
size: SIZE,
coords: position,
isLowPriority: !isHq,
isMobile,
},
);
return {
play: () => lottie.play(),
pause: () => lottie.pause(),
destroy: () => lottie.removeContainer(uniqueId),
updatePosition: (x: number, y: number, isMobileNew?: boolean) => {
return lottie.setSharedCanvasCoords(uniqueId, { x, y }, isMobileNew);
updatePosition: (x: number, y: number) => {
return lottie.setSharedCanvasCoords(uniqueId, { x, y });
},
};
}

View File

@ -4,6 +4,8 @@
}
.particle {
--color-text: var(--color-primary);
position: absolute;
width: 1rem;
height: 1rem;
@ -11,6 +13,7 @@
offset-path: var(--offset-path);
offset-rotate: 0deg;
animation: 1.5s particle ease-out;
animation-fill-mode: forwards;
}
@keyframes particle {

View File

@ -1,38 +1,58 @@
import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiReactionCustomEmoji } from '../../../api/types';
import type { ApiEmojiStatus, ApiReactionCustomEmoji } from '../../../api/types';
import { getStickerPreviewHash } from '../../../global/helpers';
import { IS_OFFSET_PATH_SUPPORTED } from '../../../util/environment';
import { getStickerPreviewHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import useMedia from '../../../hooks/useMedia';
import styles from './CustomReactionAnimation.module.scss';
import CustomEmoji from '../../common/CustomEmoji';
import styles from './CustomEmojiEffect.module.scss';
type OwnProps = {
reaction: ApiReactionCustomEmoji;
reaction: ApiReactionCustomEmoji | ApiEmojiStatus;
className?: string;
isLottie?: boolean;
};
const EFFECT_AMOUNT = 7;
const CustomReactionAnimation: FC<OwnProps> = ({
const CustomEmojiEffect: FC<OwnProps> = ({
reaction,
isLottie,
className,
}) => {
const stickerHash = getStickerPreviewHash(reaction.documentId);
const previewMediaData = useMedia(stickerHash);
const previewMediaData = useMedia(!isLottie ? stickerHash : undefined);
const paths: string[] = useMemo(() => {
if (!IS_OFFSET_PATH_SUPPORTED) return [];
return Array.from({ length: EFFECT_AMOUNT }).map(() => generateRandomDropPath());
}, []);
if (!previewMediaData) return undefined;
if (!previewMediaData && !isLottie) {
return undefined;
}
return (
<div className={styles.root}>
<div className={buildClassName(styles.root, className)}>
{paths.map((path) => {
const style = `--offset-path: path('${path}');`;
if (isLottie) {
return (
<CustomEmoji
documentId={reaction.documentId}
className={styles.particle}
style={style}
withSharedAnimation
/>
);
}
return (
<img
src={previewMediaData}
@ -46,7 +66,7 @@ const CustomReactionAnimation: FC<OwnProps> = ({
);
};
export default memo(CustomReactionAnimation);
export default memo(CustomEmojiEffect);
function generateRandomDropPath() {
const x = (10 + Math.random() * 60) * (Math.random() > 0.5 ? 1 : -1);

View File

@ -113,6 +113,7 @@ import useOuterHandlers from './hooks/useOuterHandlers';
import useInnerHandlers from './hooks/useInnerHandlers';
import useAppLayout from '../../../hooks/useAppLayout';
import useResizeObserver from '../../../hooks/useResizeObserver';
import useThrottledCallback from '../../../hooks/useThrottledCallback';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
@ -252,6 +253,7 @@ const APPEARANCE_DELAY = 10;
const NO_MEDIA_CORNERS_THRESHOLD = 18;
const QUICK_REACTION_SIZE = 1.75 * REM;
const BOTTOM_FOCUS_SCROLL_THRESHOLD = 5;
const THROTTLE_MS = 300;
const Message: FC<OwnProps & StateProps> = ({
message,
@ -613,7 +615,9 @@ const Message: FC<OwnProps & StateProps> = ({
}
}, [focusLastMessage]);
useResizeObserver(shouldFocusOnResize ? ref : undefined, handleResize, true);
const throttledResize = useThrottledCallback(handleResize, [handleResize], THROTTLE_MS, false);
useResizeObserver(shouldFocusOnResize ? ref : undefined, throttledResize);
useEffect(() => {
const bottomMarker = bottomMarkerRef.current;

View File

@ -21,7 +21,7 @@ import useCustomEmoji from '../../common/hooks/useCustomEmoji';
import CustomEmoji from '../../common/CustomEmoji';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import AnimatedSticker from '../../common/AnimatedSticker';
import CustomReactionAnimation from './CustomReactionAnimation';
import CustomEmojiEffect from './CustomEmojiEffect';
import styles from './ReactionAnimatedEmoji.module.scss';
@ -136,7 +136,7 @@ const ReactionAnimatedEmoji: FC<OwnProps> = ({
onEnded={handleEnded}
/>
{isCustom ? (
!assignedEffectId && isIntersecting && <CustomReactionAnimation reaction={reaction} />
!assignedEffectId && isIntersecting && <CustomEmojiEffect reaction={reaction} />
) : (
<AnimatedSticker
key={centerIconId}

View File

@ -134,6 +134,7 @@ const CreateTopic: FC<OwnProps & StateProps> = ({
</div>
<div className={buildClassName(styles.section, styles.bottom)}>
<CustomEmojiPicker
idPrefix="create-topic-icons-set-"
loadAndPlay={isActive}
onCustomEmojiSelect={handleCustomEmojiSelect}
className={styles.iconPicker}

View File

@ -145,6 +145,7 @@ const EditTopic: FC<OwnProps & StateProps> = ({
{!isGeneral && (
<div className={buildClassName(styles.section, styles.bottom)}>
<CustomEmojiPicker
idPrefix="edit-topic-icons-set-"
loadAndPlay={isActive}
onCustomEmojiSelect={handleCustomEmojiSelect}
className={styles.iconPicker}

View File

@ -24,6 +24,7 @@ type OwnProps = {
isOpen: boolean;
id?: string;
className?: string;
bubbleClassName?: string;
style?: string;
bubbleStyle?: string;
ariaLabelledBy?: string;
@ -54,6 +55,7 @@ const Menu: FC<OwnProps> = ({
isOpen,
id,
className,
bubbleClassName,
style,
bubbleStyle,
ariaLabelledBy,
@ -116,12 +118,13 @@ const Menu: FC<OwnProps> = ({
noCloseOnBackdrop ? undefined : onClose,
);
const bubbleClassName = buildClassName(
const bubbleFullClassName = buildClassName(
'bubble menu-container custom-scroll',
positionY,
positionX,
footer && 'with-footer',
transitionClassNames,
bubbleClassName,
);
const transformOriginYStyle = transformOriginY !== undefined ? `${transformOriginY}px` : undefined;
@ -154,7 +157,7 @@ const Menu: FC<OwnProps> = ({
<div
role="presentation"
ref={menuRef}
className={bubbleClassName}
className={bubbleFullClassName}
style={buildStyle(
`transform-origin: ${transformOriginXStyle || positionX} ${transformOriginYStyle || positionY}`,
bubbleStyle,

View File

@ -8,14 +8,13 @@ import { IS_COMPACT_MENU } from '../../util/environment';
import './MenuItem.scss';
type OnClickHandler = (e: React.SyntheticEvent<HTMLDivElement | HTMLAnchorElement>) => void;
export type MenuItemProps = {
icon?: string;
customIcon?: React.ReactNode;
className?: string;
children: React.ReactNode;
onClick?: OnClickHandler;
onClick?: (e: React.SyntheticEvent<HTMLDivElement | HTMLAnchorElement>, arg?: number) => void;
clickArg?: number;
onContextMenu?: (e: React.UIEvent) => void;
href?: string;
download?: string;
@ -39,6 +38,7 @@ const MenuItem: FC<MenuItemProps> = (props) => {
ariaLabel,
withWrap,
onContextMenu,
clickArg,
} = props;
const lang = useLang();
@ -50,8 +50,8 @@ const MenuItem: FC<MenuItemProps> = (props) => {
return;
}
onClick(e);
}, [disabled, onClick]);
onClick(e, clickArg);
}, [clickArg, disabled, onClick]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.keyCode !== 13 && e.keyCode !== 32) {
@ -65,8 +65,8 @@ const MenuItem: FC<MenuItemProps> = (props) => {
return;
}
onClick(e);
}, [disabled, onClick]);
onClick(e, clickArg);
}, [clickArg, disabled, onClick]);
const fullClassName = buildClassName(
'MenuItem',

View File

@ -168,6 +168,7 @@ export const STICKER_SIZE_INLINE_BOT_RESULT = 100;
export const STICKER_SIZE_JOIN_REQUESTS = 140;
export const STICKER_SIZE_INVITES = 140;
export const RECENT_STICKERS_LIMIT = 20;
export const RECENT_STATUS_LIMIT = 20;
export const EMOJI_STATUS_LOOP_LIMIT = 2;
export const EMOJI_SIZES = 7;
export const RECENT_SYMBOL_SET_ID = 'recent';
@ -175,6 +176,7 @@ export const FAVORITE_SYMBOL_SET_ID = 'favorite';
export const CHAT_STICKER_SET_ID = 'chatStickers';
export const PREMIUM_STICKER_SET_ID = 'premium';
export const DEFAULT_TOPIC_ICON_STICKER_ID = 'topic-default-icon';
export const DEFAULT_STATUS_ICON_ID = 'status-default-icon';
export const EMOJI_IMG_REGEX = /<img[^>]+alt="([^"]+)"(?![^>]*data-document-id)[^>]*>/gm;
export const BASE_EMOJI_KEYWORD_LANG = 'en';

View File

@ -18,7 +18,9 @@ import {
updateStickersForEmoji,
rebuildStickersForEmoji,
updateCustomEmojiForEmoji,
updateCustomEmojiSets, updateStickerSearch,
updateCustomEmojiSets,
updateRecentStatusCustomEmojis,
updateStickerSearch,
} from '../../reducers';
import searchWords from '../../../util/searchWords';
import { selectTabState, selectIsCurrentUserPremium, selectStickerSet } from '../../selectors';
@ -246,6 +248,21 @@ addActionHandler('loadDefaultTopicIcons', async (global): Promise<void> => {
setGlobal(global);
});
addActionHandler('loadDefaultStatusIcons', async (global): Promise<void> => {
const stickerSet = await callApi('fetchDefaultStatusEmojis');
if (!stickerSet) {
return;
}
global = getGlobal();
const { set, stickers } = stickerSet;
const fullSet = { ...set, stickers };
global = updateStickerSet(global, fullSet.id, fullSet);
global = { ...global, defaultStatusIconsId: fullSet.id };
setGlobal(global);
});
addActionHandler('loadStickers', (global, actions, payload): ActionReturnType => {
const { stickerSetInfo, tabId = getCurrentTabId() } = payload;
const cachedSet = selectStickerSet(global, stickerSetInfo);
@ -746,6 +763,17 @@ addActionHandler('openStickerSet', async (global, actions, payload): Promise<voi
setGlobal(global);
});
addActionHandler('loadRecentEmojiStatuses', async (global): Promise<void> => {
const result = await callApi('fetchRecentEmojiStatuses');
if (!result) {
return;
}
global = getGlobal();
global = updateRecentStatusCustomEmojis(global, result.hash, result.emojiStatuses!);
setGlobal(global);
});
async function searchGifs<T extends GlobalState>(global: T, query: string, botUsername?: string, offset?: string,
...[tabId = getCurrentTabId()]: TabArgs<T>) {
const result = await callApi('searchGifs', { query, offset, username: botUsername });

View File

@ -347,3 +347,9 @@ addActionHandler('reportSpam', (global, actions, payload): ActionReturnType => {
void callApi('reportSpam', userOrChat);
});
addActionHandler('setEmojiStatus', (global, actions, payload): ActionReturnType => {
const { emojiStatus, expires } = payload!;
void callApi('updateEmojiStatus', emojiStatus, expires);
});

View File

@ -38,6 +38,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.loadRecentStickers();
break;
case 'updateRecentEmojiStatuses':
actions.loadRecentEmojiStatuses();
break;
case 'updateMoveStickerSetToTop': {
const oldOrder = update.isCustomEmoji ? global.customEmojis.added.setIds : global.stickers.added.setIds;
if (!oldOrder) return global;

View File

@ -249,9 +249,14 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
byId: {},
lastRendered: [],
forEmoji: {},
statusRecent: {},
};
}
if (!cached.customEmojis.statusRecent) {
cached.customEmojis.statusRecent = {};
}
if (!cached.recentCustomEmojis) {
cached.recentCustomEmojis = [];
}
@ -426,6 +431,7 @@ function reduceCustomEmojis<T extends GlobalState>(global: T): GlobalState['cust
lastRendered: idsToSave,
forEmoji: {},
added: {},
statusRecent: {},
};
}

View File

@ -107,6 +107,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
byId: {},
added: {},
forEmoji: {},
statusRecent: {},
},
emojiKeywords: {},

View File

@ -242,6 +242,22 @@ export function updateCustomEmojiForEmoji<T extends GlobalState>(
};
}
export function updateRecentStatusCustomEmojis<T extends GlobalState>(
global: T, hash: string, emojis: ApiSticker[],
): T {
return {
...global,
customEmojis: {
...global.customEmojis,
statusRecent: {
...global.customEmojis.statusRecent,
hash,
emojis,
},
},
};
}
export function rebuildStickersForEmoji<T extends GlobalState>(global: T): T {
if (global.stickers.forEmoji) {
const { emoji, stickers, hash } = global.stickers.forEmoji;

View File

@ -718,12 +718,17 @@ export type GlobalState = {
stickers?: ApiSticker[];
};
featuredIds?: string[];
statusRecent: {
hash?: string;
emojis?: ApiSticker[];
};
};
animatedEmojis?: ApiStickerSet;
animatedEmojiEffects?: ApiStickerSet;
genericEmojiEffects?: ApiStickerSet;
defaultTopicIconsId?: string;
defaultStatusIconsId?: string;
premiumGifts?: ApiStickerSet;
emojiKeywords: Partial<Record<LangCode, EmojiKeywords>>;
@ -1886,6 +1891,8 @@ export interface ActionPayloads {
};
clearRecentCustomEmoji: undefined;
loadFeaturedEmojiStickers: undefined;
loadDefaultStatusIcons: undefined;
loadRecentEmojiStatuses: undefined;
// Bots
sendBotCommand: {
@ -2212,6 +2219,10 @@ export interface ActionPayloads {
} & WithTabId) | undefined;
closeGiftPremiumModal: WithTabId | undefined;
setEmojiStatus: {
emojiStatus: ApiSticker;
expires?: number;
};
// Invoice
openInvoice: ApiInputInvoice & WithTabId;

View File

@ -5,8 +5,10 @@ import {
import { round } from '../util/math';
import useResizeObserver from './useResizeObserver';
import useThrottledCallback from './useThrottledCallback';
const ANIMATION_END_TIMEOUT = 500;
const THROTTLE_MS = 300;
export default function useBoundsInSharedCanvas(
containerRef: React.RefObject<HTMLDivElement>,
@ -39,9 +41,11 @@ export default function useBoundsInSharedCanvas(
setSize(Math.round(targetBounds.width));
}, [containerRef, sharedCanvasRef]);
const throttledRecalculate = useThrottledCallback(recalculate, [recalculate], THROTTLE_MS, false);
useLayoutEffect(recalculate, [recalculate]);
useResizeObserver(sharedCanvasRef, recalculate, true);
useResizeObserver(sharedCanvasRef, throttledRecalculate);
const coords = useMemo(() => (x !== undefined && y !== undefined ? { x, y } : undefined), [x, y]);

View File

@ -1,18 +1,20 @@
import { useEffect } from '../lib/teact/teact';
import { throttle } from '../util/schedulers';
const THROTTLE = 300;
import type { CallbackManager } from '../util/callbacks';
import { createCallbackManager } from '../util/callbacks';
const elementObserverMap = new Map<HTMLElement, [ResizeObserver, CallbackManager]>();
export default function useResizeObserver(
ref: React.RefObject<HTMLElement> | undefined,
onResize: (entry: ResizeObserverEntry) => void,
withThrottle = false,
) {
useEffect(() => {
if (!('ResizeObserver' in window) || !ref?.current) {
return undefined;
}
const el = ref.current;
const callback: ResizeObserverCallback = ([entry]) => {
// During animation
if (!(entry.target as HTMLElement).offsetParent) {
@ -21,12 +23,23 @@ export default function useResizeObserver(
onResize(entry);
};
const observer = new ResizeObserver(withThrottle ? throttle(callback, THROTTLE, false) : callback);
observer.observe(ref.current);
let [observer, callbackManager] = elementObserverMap.get(el) || [undefined, undefined];
if (!observer) {
callbackManager = createCallbackManager();
observer = new ResizeObserver(callbackManager.runCallbacks);
elementObserverMap.set(el, [observer, callbackManager]);
observer.observe(el);
}
callbackManager!.addCallback(callback);
return () => {
observer.disconnect();
callbackManager!.removeCallback(callback);
if (!callbackManager!.hasCallbacks()) {
observer!.unobserve(el);
observer!.disconnect();
elementObserverMap.delete(el);
}
};
}, [onResize, ref, withThrottle]);
}, [onResize, ref]);
}

View File

@ -1127,6 +1127,8 @@ account.setGlobalPrivacySettings#1edaaac2 settings:GlobalPrivacySettings = Globa
account.reportProfilePhoto#fa8cc6f5 peer:InputPeer photo_id:InputPhoto reason:ReportReason message:string = Bool;
account.setAuthorizationTTL#bf899aa0 authorization_ttl_days:int = Bool;
account.changeAuthorizationSettings#40f48462 flags:# hash:long encrypted_requests_disabled:flags.0?Bool call_requests_disabled:flags.1?Bool = Bool;
account.updateEmojiStatus#fbd3de6b emoji_status:EmojiStatus = Bool;
account.getRecentEmojiStatuses#f578105 hash:long = account.EmojiStatuses;
account.reorderUsernames#ef500eab order:Vector<string> = Bool;
account.toggleUsername#58d6b376 username:string active:Bool = Bool;
users.getUsers#d91a548 id:Vector<InputUser> = Vector<User>;

View File

@ -53,6 +53,8 @@
"account.reportProfilePhoto",
"account.changeAuthorizationSettings",
"account.setAuthorizationTTL",
"account.updateEmojiStatus",
"account.getRecentEmojiStatuses",
"account.reorderUsernames",
"account.toggleUsername",
"users.getUsers",

View File

@ -1,4 +1,6 @@
import { DPR, IS_SAFARI, IS_ANDROID } from '../../util/environment';
import {
DPR, IS_SAFARI, IS_ANDROID, IS_IOS,
} from '../../util/environment';
import WorkerConnector from '../../util/WorkerConnector';
import { animate } from '../../util/animation';
import cycleRestrict from '../../util/cycleRestrict';
@ -9,7 +11,6 @@ interface Params {
size?: number;
quality?: number;
isLowPriority?: boolean;
isMobile?: boolean;
coords?: { x: number; y: number };
}
@ -20,8 +21,7 @@ type Frame =
| ImageBitmap;
const MAX_WORKERS = 4;
const HIGH_PRIORITY_QUALITY_MOBILE = 0.75;
const HIGH_PRIORITY_QUALITY_DESKTOP = 1;
const HIGH_PRIORITY_QUALITY = (IS_ANDROID || IS_IOS) ? 0.75 : 1;
const LOW_PRIORITY_QUALITY = IS_ANDROID ? 0.5 : 0.75;
const LOW_PRIORITY_QUALITY_SIZE_THRESHOLD = 24;
const HIGH_PRIORITY_CACHE_MODULO = IS_SAFARI ? 2 : 4;
@ -96,7 +96,7 @@ class RLottie {
instance = new RLottie(...args);
instancesById.set(id, instance);
} else {
instance.addContainer(container, canvas, onLoad, params?.coords, params?.isMobile);
instance.addContainer(container, canvas, onLoad, params?.coords);
}
return instance;
@ -113,7 +113,7 @@ class RLottie {
private onEnded?: (isDestroyed?: boolean) => void,
private onLoop?: () => void,
) {
this.addContainer(containerId, container, onLoad, params.coords, params.isMobile);
this.addContainer(containerId, container, onLoad, params.coords);
this.initConfig();
this.initRenderer();
}
@ -200,18 +200,13 @@ class RLottie {
this.params.noLoop = noLoop;
}
setIsMobile(isMobile?: Params['isMobile']) {
this.params.isMobile = isMobile;
}
setSharedCanvasCoords(containerId: string, newCoords: Params['coords'], isMobile?: Params['isMobile']) {
setSharedCanvasCoords(containerId: string, newCoords: Params['coords']) {
const containerInfo = this.containers.get(containerId)!;
const {
canvas, ctx,
} = containerInfo;
if (!canvas.dataset.isJustCleaned || canvas.dataset.isJustCleaned === 'false') {
this.setIsMobile(isMobile);
const sizeFactor = this.calcSizeFactor();
ensureCanvasSize(canvas, sizeFactor);
ctx.clearRect(0, 0, canvas.width, canvas.height);
@ -238,9 +233,7 @@ class RLottie {
container: HTMLDivElement | HTMLCanvasElement,
onLoad?: NoneToVoidFunction,
coords?: Params['coords'],
isMobile?: Params['isMobile'],
) {
this.setIsMobile(isMobile);
const sizeFactor = this.calcSizeFactor();
let imgSize: number;
@ -318,11 +311,9 @@ class RLottie {
const {
isLowPriority,
size,
isMobile,
// Reduced quality only looks acceptable on big enough images
quality = isLowPriority && (!size || size > LOW_PRIORITY_QUALITY_SIZE_THRESHOLD)
? LOW_PRIORITY_QUALITY
: (isMobile ? HIGH_PRIORITY_QUALITY_MOBILE : HIGH_PRIORITY_QUALITY_DESKTOP),
? LOW_PRIORITY_QUALITY : HIGH_PRIORITY_QUALITY,
} = this.params;
// Reduced quality only looks acceptable on high DPR screens