diff --git a/package-lock.json b/package-lock.json index 453048bf7..431e1ef33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 55b0e8f10..bacfd8fa0 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -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 }), diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index d95c7c3db..935574203 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -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), + }); +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 371767c9b..3b9216529 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -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 { diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index 2d47beaae..d6dca1b47 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -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) => { diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 4f7cf9bcd..70f689477 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -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); diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 6c36480a0..a21fa3419 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -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); diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index e652161da..c42edb96b 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -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; diff --git a/src/assets/premium/PremiumStatus.svg b/src/assets/premium/PremiumStatus.svg new file mode 100644 index 000000000..e9e0d4c65 --- /dev/null +++ b/src/assets/premium/PremiumStatus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 7caec368e..afb940f89 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/calls/group/GroupCall.tsx b/src/components/calls/group/GroupCall.tsx index 2a17915d7..a3bed1644 100644 --- a/src/components/calls/group/GroupCall.tsx +++ b/src/components/calls/group/GroupCall.tsx @@ -108,7 +108,6 @@ const GroupCall: FC = ({ 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!; diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index 09804f2c9..a94bcd821 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -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; @@ -89,8 +88,8 @@ const AnimatedSticker: FC = ({ const containerId = useMemo(() => generateIdFor(ID_STORE, true), []); - const { isMobile } = useAppLayout(); const [animation, setAnimation] = useState(); + const animationRef = useRef(); const wasPlaying = useRef(false); const isFrozen = useRef(false); const isFirstRender = useRef(true); @@ -140,7 +139,6 @@ const AnimatedSticker: FC = ({ quality, isLowPriority, coords: sharedCanvasCoords, - isMobile, }, color, onEnded, @@ -152,6 +150,7 @@ const AnimatedSticker: FC = ({ } setAnimation(newAnimation); + animationRef.current = newAnimation; }; if (RLottie) { @@ -167,7 +166,7 @@ const AnimatedSticker: FC = ({ } }, [ 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 = ({ 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 = ({ } }, [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) { diff --git a/src/components/common/StickerButton.scss b/src/components/common/StickerButton.scss index f0a8187cf..bfd1f7c5c 100644 --- a/src/components/common/StickerButton.scss +++ b/src/components/common/StickerButton.scss @@ -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; } } diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index 81d4bbed6..fe663d5ec 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -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 = { className?: string; noContextMenu?: boolean; isSavedMessages?: boolean; + isStatusPicker?: boolean; canViewSet?: boolean; isCurrentUserPremium?: boolean; sharedCanvasRef?: React.RefObject; @@ -43,8 +45,19 @@ type OwnProps = { 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 = ({ sticker, size, @@ -53,6 +66,7 @@ const StickerButton = ) => { - const { openStickerSet, openPremiumModal } = getActions(); + const { openStickerSet, openPremiumModal, setEmojiStatus } = getActions(); // eslint-disable-next-line no-null/no-null const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const menuRef = useRef(null); const lang = useLang(); const [customColor, setCustomColor] = useState<[number, number, number] | undefined>(); const hasCustomColor = sticker.shouldUseTextColor; @@ -99,6 +118,7 @@ const StickerButton = ref.current, []); @@ -108,10 +128,14 @@ const StickerButton = 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 = { + if (isContextMenuOpen) { + onContextMenuOpen?.(); + } else { + onContextMenuClose?.(); + } + }, [isContextMenuOpen, onContextMenuClose, onContextMenuOpen]); + useEffect(() => { if (!isIntersecting) handleContextMenuClose(); }, [handleContextMenuClose, isIntersecting]); @@ -170,6 +203,18 @@ const StickerButton = { + 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 = { - if (noContextMenu || isCustomEmoji) return []; + if (!shouldRenderContextMenu || noContextMenu || (isCustomEmoji && !isStatusPicker)) return []; const items: ReactNode[] = []; + if (isCustomEmoji) { + contentForStatusMenuContext.forEach((item) => { + items.push( + + {lang(item.title, item.value, 'i')} + , + ); + }); + + return items; + } + if (onUnfaveClick) { items.push( @@ -228,9 +285,9 @@ const StickerButton = )} - {Boolean(contextMenuItems.length) && contextMenuPosition !== undefined && ( + {Boolean(contextMenuItems.length) && ( diff --git a/src/components/common/StickerView.tsx b/src/components/common/StickerView.tsx index 6ed09fbff..d9d0b6d1b 100644 --- a/src/components/common/StickerView.tsx +++ b/src/components/common/StickerView.tsx @@ -115,7 +115,7 @@ const StickerView: FC = ({ 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); diff --git a/src/components/left/main/LeftMainHeader.scss b/src/components/left/main/LeftMainHeader.scss index 9c8cee259..8395ee22c 100644 --- a/src/components/left/main/LeftMainHeader.scss +++ b/src/components/left/main/LeftMainHeader.scss @@ -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 { diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index 381574805..a80ff5474 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -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; isMessageListOpen: boolean; + isCurrentUserPremium?: boolean; isConnectionStatusMinimized: ISettings['isConnectionStatusMinimized']; areChatsLoaded?: boolean; hasPasscode?: boolean; @@ -92,6 +96,7 @@ const LeftMainHeader: FC = ({ onReset, searchQuery, isLoading, + isCurrentUserPremium, shouldSkipTransition, currentUserId, globalSearchChatId, @@ -430,6 +435,7 @@ const LeftMainHeader: FC = ({ /> )} + {isCurrentUserPremium && } {hasPasscode && ( @@ -482,6 +488,7 @@ export default memo(withGlobal( isSyncing, isMessageListOpen: Boolean(selectCurrentMessageList(global)), isConnectionStatusMinimized, + isCurrentUserPremium: selectIsCurrentUserPremium(global), areChatsLoaded: Boolean(global.chats.listIds.active), hasPasscode: Boolean(global.passcode.hasPasscode), canInstall: Boolean(tabState.canInstall), diff --git a/src/components/left/main/StatusButton.tsx b/src/components/left/main/StatusButton.tsx new file mode 100644 index 000000000..302ea9068 --- /dev/null +++ b/src/components/left/main/StatusButton.tsx @@ -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 = ({ emojiStatus }) => { + const { setEmojiStatus, loadCurrentUser } = getActions(); + + // eslint-disable-next-line no-null/no-null + const buttonRef = useRef(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 ( +
+ {Boolean(isEffectShown && emojiStatus) && ( + + )} + + +
+ ); +}; + +export default memo(withGlobal((global) => { + const { currentUserId } = global; + const currentUser = currentUserId ? selectUser(global, currentUserId) : undefined; + + return { + emojiStatus: currentUser?.emojiStatus, + }; +})(StatusButton)); diff --git a/src/components/left/main/StatusPickerMenu.async.tsx b/src/components/left/main/StatusPickerMenu.async.tsx new file mode 100644 index 000000000..328567f1f --- /dev/null +++ b/src/components/left/main/StatusPickerMenu.async.tsx @@ -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 = (props) => { + const { isOpen } = props; + const StatusPickerMenu = useModuleLoader(Bundles.Extra, 'StatusPickerMenu', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return StatusPickerMenu ? : undefined; +}; + +export default memo(StatusPickerMenuAsync); diff --git a/src/components/left/main/StatusPickerMenu.module.scss b/src/components/left/main/StatusPickerMenu.module.scss new file mode 100644 index 000000000..d756e1b4f --- /dev/null +++ b/src/components/left/main/StatusPickerMenu.module.scss @@ -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); + } +} diff --git a/src/components/left/main/StatusPickerMenu.tsx b/src/components/left/main/StatusPickerMenu.tsx new file mode 100644 index 000000000..4e7f780d1 --- /dev/null +++ b/src/components/left/main/StatusPickerMenu.tsx @@ -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; + onEmojiStatusSelect: (emojiStatus: ApiSticker) => void; + onClose: () => void; +}; + +interface StateProps { + areFeaturedStickersLoaded?: boolean; +} + +const StatusPickerMenu: FC = ({ + isOpen, + statusButtonRef, + areFeaturedStickersLoaded, + onEmojiStatusSelect, + onClose, +}) => { + const { loadFeaturedEmojiStickers } = getActions(); + + const transformOriginX = useRef(); + 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 ( + + + + + + ); +}; + +export default memo(withGlobal((global): StateProps => { + return { + areFeaturedStickersLoaded: Boolean(global.customEmojis.featuredIds?.length), + }; +})(StatusPickerMenu)); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 3f5d2878f..610efea43 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -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 = ({ isPremiumModalOpen, isPaymentModalOpen, isReceiptModalOpen, + isCurrentUserPremium, deleteFolderDialogId, isMasterTab, }) => { @@ -194,6 +197,7 @@ const Main: FC = ({ loadDefaultTopicIcons, loadAddedStickers, loadFavoriteStickers, + loadDefaultStatusIcons, ensureTimeFormat, closeStickerSetModal, closeCustomEmojiSets, @@ -209,6 +213,7 @@ const Main: FC = ({ checkAppVersion, openChat, toggleLeftColumn, + loadRecentEmojiStatuses, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -245,12 +250,17 @@ const Main: FC = ({ 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( webApp, currentUser, urlAuth, + isCurrentUserPremium: selectIsCurrentUserPremium(global), isPremiumModalOpen: premiumModal?.isOpen, limitReached: limitReachedModal?.limit, isPaymentModalOpen: payment.isPaymentModalOpen, diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index 7030890d4..caaca1c86 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -34,6 +34,7 @@ export const PREMIUM_FEATURE_TITLES: Record = { more_upload: 'PremiumPreviewUploads', advanced_chat_management: 'PremiumPreviewAdvancedChatManagement', animated_userpics: 'PremiumPreviewAnimatedProfiles', + emoji_status: 'PremiumPreviewEmojiStatus', }; export const PREMIUM_FEATURE_DESCRIPTIONS: Record = { @@ -48,6 +49,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record = { 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; diff --git a/src/components/main/premium/PremiumMainModal.tsx b/src/components/main/premium/PremiumMainModal.tsx index b035b0802..f913ab7e3 100644 --- a/src/components/main/premium/PremiumMainModal.tsx +++ b/src/components/main/premium/PremiumMainModal.tsx @@ -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 = { more_upload: PremiumFile, advanced_chat_management: PremiumChats, animated_userpics: PremiumVideo, + emoji_status: PremiumStatus, }; export type OwnProps = { @@ -176,6 +178,11 @@ const PremiumMainModal: FC = ({ 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 = ({
- {(premiumPromoOrder || PREMIUM_FEATURE_SECTIONS).map((section, index) => { - if (!PREMIUM_FEATURE_SECTIONS.includes(section)) return undefined; + {filteredSections.map((section, index) => { return ( void; + onContextMenuOpen?: NoneToVoidFunction; + onContextMenuClose?: NoneToVoidFunction; + onContextMenuClick?: NoneToVoidFunction; }; type StateProps = { + customEmojisById?: Record; + recentCustomEmojiIds?: string[]; + recentStatusEmojis?: ApiSticker[]; stickerSetsById: Record; addedCustomEmojiIds?: string[]; - customEmojisById: Record; - 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 = ({ 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(null); @@ -92,17 +108,23 @@ const CustomEmojiPicker: FC = ({ const [activeSetIndex, setActiveSetIndex] = useState(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 = ({ 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 = ({ 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 = ({ 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 = ({ ...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 = ({ 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 = ({ 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} /> ))}
@@ -314,7 +349,7 @@ const CustomEmojiPicker: FC = ({ }; export default memo(withGlobal( - (global, { chatId }): StateProps => { + (global, { chatId, isStatusPicker }): StateProps => { const { stickers: { setsById: stickerSetsById, @@ -322,6 +357,9 @@ export default memo(withGlobal( customEmojis: { byId: customEmojisById, featuredIds: customEmojiFeaturedIds, + statusRecent: { + emojis: recentStatusEmojis, + }, }, recentCustomEmojis: recentCustomEmojiIds, } = global; @@ -329,15 +367,17 @@ export default memo(withGlobal( 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)); diff --git a/src/components/middle/composer/StickerPicker.scss b/src/components/middle/composer/StickerPicker.scss index c41e85ff2..7edfbea00 100644 --- a/src/components/middle/composer/StickerPicker.scss +++ b/src/components/middle/composer/StickerPicker.scss @@ -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; + } } diff --git a/src/components/middle/composer/StickerSet.tsx b/src/components/middle/composer/StickerSet.tsx index f732a28af..5f0f513b1 100644 --- a/src/components/middle/composer/StickerSet.tsx +++ b/src/components/middle/composer/StickerSet.tsx @@ -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 = ({ 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 = ({ 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 = ({ } 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 = ({
= ({ Reset )} + {withDefaultStatusIcon && ( + + )} {shouldRender && stickerSet.stickers && stickerSet.stickers .slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length) .map((sticker, i) => { @@ -248,6 +280,7 @@ const StickerSet: FC = ({ observeIntersection={observeIntersection} noAnimate={!loadAndPlay} isSavedMessages={isSavedMessages} + isStatusPicker={isStatusPicker} canViewSet isCurrentUserPremium={isCurrentUserPremium} sharedCanvasRef={canvasRef} @@ -256,6 +289,9 @@ const StickerSet: FC = ({ 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} /> ); })} diff --git a/src/components/middle/composer/hooks/useInputCustomEmojis.ts b/src/components/middle/composer/hooks/useInputCustomEmojis.ts index 85c0c2e26..a101951a2 100644 --- a/src/components/middle/composer/hooks/useInputCustomEmojis.ts +++ b/src/components/middle/composer/hooks/useInputCustomEmojis.ts @@ -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>(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; @@ -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 }); }, }; } diff --git a/src/components/middle/message/CustomReactionAnimation.module.scss b/src/components/middle/message/CustomEmojiEffect.module.scss similarity index 86% rename from src/components/middle/message/CustomReactionAnimation.module.scss rename to src/components/middle/message/CustomEmojiEffect.module.scss index 20e194e5d..a8e867fba 100644 --- a/src/components/middle/message/CustomReactionAnimation.module.scss +++ b/src/components/middle/message/CustomEmojiEffect.module.scss @@ -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 { diff --git a/src/components/middle/message/CustomReactionAnimation.tsx b/src/components/middle/message/CustomEmojiEffect.tsx similarity index 55% rename from src/components/middle/message/CustomReactionAnimation.tsx rename to src/components/middle/message/CustomEmojiEffect.tsx index 22f6fbc93..6bd9b034f 100644 --- a/src/components/middle/message/CustomReactionAnimation.tsx +++ b/src/components/middle/message/CustomEmojiEffect.tsx @@ -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 = ({ +const CustomEmojiEffect: FC = ({ 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 ( -
+
{paths.map((path) => { const style = `--offset-path: path('${path}');`; + if (isLottie) { + return ( + + ); + } + return ( = ({ ); }; -export default memo(CustomReactionAnimation); +export default memo(CustomEmojiEffect); function generateRandomDropPath() { const x = (10 + Math.random() * 60) * (Math.random() > 0.5 ? 1 : -1); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 6c88eca76..79bdb4bb0 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -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 = ({ message, @@ -613,7 +615,9 @@ const Message: FC = ({ } }, [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; diff --git a/src/components/middle/message/ReactionAnimatedEmoji.tsx b/src/components/middle/message/ReactionAnimatedEmoji.tsx index 0f7e19798..d8cecc21f 100644 --- a/src/components/middle/message/ReactionAnimatedEmoji.tsx +++ b/src/components/middle/message/ReactionAnimatedEmoji.tsx @@ -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 = ({ onEnded={handleEnded} /> {isCustom ? ( - !assignedEffectId && isIntersecting && + !assignedEffectId && isIntersecting && ) : ( = ({
= ({ {!isGeneral && (
= ({ isOpen, id, className, + bubbleClassName, style, bubbleStyle, ariaLabelledBy, @@ -116,12 +118,13 @@ const Menu: FC = ({ 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 = ({
) => void; - export type MenuItemProps = { icon?: string; customIcon?: React.ReactNode; className?: string; children: React.ReactNode; - onClick?: OnClickHandler; + onClick?: (e: React.SyntheticEvent, arg?: number) => void; + clickArg?: number; onContextMenu?: (e: React.UIEvent) => void; href?: string; download?: string; @@ -39,6 +38,7 @@ const MenuItem: FC = (props) => { ariaLabel, withWrap, onContextMenu, + clickArg, } = props; const lang = useLang(); @@ -50,8 +50,8 @@ const MenuItem: FC = (props) => { return; } - onClick(e); - }, [disabled, onClick]); + onClick(e, clickArg); + }, [clickArg, disabled, onClick]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.keyCode !== 13 && e.keyCode !== 32) { @@ -65,8 +65,8 @@ const MenuItem: FC = (props) => { return; } - onClick(e); - }, [disabled, onClick]); + onClick(e, clickArg); + }, [clickArg, disabled, onClick]); const fullClassName = buildClassName( 'MenuItem', diff --git a/src/config.ts b/src/config.ts index bbac5f6bb..39ed5cd24 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 = /]+alt="([^"]+)"(?![^>]*data-document-id)[^>]*>/gm; export const BASE_EMOJI_KEYWORD_LANG = 'en'; diff --git a/src/global/actions/api/symbols.ts b/src/global/actions/api/symbols.ts index b9e6709d5..b8a01cb48 100644 --- a/src/global/actions/api/symbols.ts +++ b/src/global/actions/api/symbols.ts @@ -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 => { setGlobal(global); }); +addActionHandler('loadDefaultStatusIcons', async (global): Promise => { + 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 => { + const result = await callApi('fetchRecentEmojiStatuses'); + if (!result) { + return; + } + + global = getGlobal(); + global = updateRecentStatusCustomEmojis(global, result.hash, result.emojiStatuses!); + setGlobal(global); +}); + async function searchGifs(global: T, query: string, botUsername?: string, offset?: string, ...[tabId = getCurrentTabId()]: TabArgs) { const result = await callApi('searchGifs', { query, offset, username: botUsername }); diff --git a/src/global/actions/api/users.ts b/src/global/actions/api/users.ts index 46fbbab6f..8dddad94a 100644 --- a/src/global/actions/api/users.ts +++ b/src/global/actions/api/users.ts @@ -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); +}); diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index 89fe22424..acc63c7d2 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -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; diff --git a/src/global/cache.ts b/src/global/cache.ts index 3c03b5b75..62b4867d0 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -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(global: T): GlobalState['cust lastRendered: idsToSave, forEmoji: {}, added: {}, + statusRecent: {}, }; } diff --git a/src/global/initialState.ts b/src/global/initialState.ts index a5bc4fb32..ceb84672f 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -107,6 +107,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { byId: {}, added: {}, forEmoji: {}, + statusRecent: {}, }, emojiKeywords: {}, diff --git a/src/global/reducers/symbols.ts b/src/global/reducers/symbols.ts index 83964168d..28a20d5d4 100644 --- a/src/global/reducers/symbols.ts +++ b/src/global/reducers/symbols.ts @@ -242,6 +242,22 @@ export function updateCustomEmojiForEmoji( }; } +export function updateRecentStatusCustomEmojis( + global: T, hash: string, emojis: ApiSticker[], +): T { + return { + ...global, + customEmojis: { + ...global.customEmojis, + statusRecent: { + ...global.customEmojis.statusRecent, + hash, + emojis, + }, + }, + }; +} + export function rebuildStickersForEmoji(global: T): T { if (global.stickers.forEmoji) { const { emoji, stickers, hash } = global.stickers.forEmoji; diff --git a/src/global/types.ts b/src/global/types.ts index dc07404e2..01057e3ef 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -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>; @@ -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; diff --git a/src/hooks/useBoundsInSharedCanvas.ts b/src/hooks/useBoundsInSharedCanvas.ts index e1b471a72..aa8bcb989 100644 --- a/src/hooks/useBoundsInSharedCanvas.ts +++ b/src/hooks/useBoundsInSharedCanvas.ts @@ -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, @@ -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]); diff --git a/src/hooks/useResizeObserver.ts b/src/hooks/useResizeObserver.ts index 97f338c04..ed06aa628 100644 --- a/src/hooks/useResizeObserver.ts +++ b/src/hooks/useResizeObserver.ts @@ -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(); export default function useResizeObserver( ref: React.RefObject | 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]); } diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 753f6be57..2a2ef651d 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -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 = Bool; account.toggleUsername#58d6b376 username:string active:Bool = Bool; users.getUsers#d91a548 id:Vector = Vector; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index c78736323..3de677585 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -53,6 +53,8 @@ "account.reportProfilePhoto", "account.changeAuthorizationSettings", "account.setAuthorizationTTL", + "account.updateEmojiStatus", + "account.getRecentEmojiStatuses", "account.reorderUsernames", "account.toggleUsername", "users.getUsers", diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index 46342849f..8559603ce 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -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