diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 76c3e1fe9..673dc6e67 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -38,6 +38,10 @@ export interface GramJsAppConfig extends LimitsConfig { file_reference_base64: string; }>; emojies_send_dice: string[]; + emojies_send_dice_success: Record; groupcall_video_participants_max: number; reactions_uniq_max: number; chat_read_mark_size_threshold: number; @@ -144,6 +148,17 @@ function buildEmojiSounds(appConfig: GramJsAppConfig) { }, {}) : {}; } +function buildDiceEmojiesSuccess(appConfig: GramJsAppConfig) { + const { emojies_send_dice_success } = appConfig; + return emojies_send_dice_success ? Object.entries(emojies_send_dice_success).reduce((acc, [key, value]) => { + acc[key] = { + value: value.value, + frameStart: value.frame_start, + }; + return acc; + }, {} as ApiAppConfig['diceEmojiesSuccess']) : {}; +} + function getLimit(appConfig: GramJsAppConfig, key: Limit, fallbackKey: ApiLimitType) { const defaultLimit = appConfig[`${key}_default`] || DEFAULT_LIMITS[fallbackKey][0]; const premiumLimit = appConfig[`${key}_premium`] || DEFAULT_LIMITS[fallbackKey][1]; @@ -251,6 +266,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp whitelistedBotIds: appConfig.whitelisted_bots, arePasskeysAvailable: appConfig.settings_display_passkeys, passkeysMaxCount: appConfig.passkeys_account_passkeys_max, + diceEmojies: appConfig.emojies_send_dice, + diceEmojiesSuccess: buildDiceEmojiesSuccess(appConfig), }; return { diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index 80bbe1fe5..848941142 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -3,6 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiAudio, ApiContact, + ApiDice, ApiDocument, ApiFormattedText, ApiGame, @@ -179,13 +180,16 @@ export function buildMessageMediaContent( const game = buildGameFromMedia(media); if (game) return { game }; + const dice = buildDiceFromMedia(media); + if (dice) return { dice }; + const storyData = buildMessageStoryData(media); if (storyData) return { storyData }; - const giveaway = buildGiweawayFromMedia(media); + const giveaway = buildGiveawayFromMedia(media); if (giveaway) return { giveaway }; - const giveawayResults = buildGiweawayResultsFromMedia(media); + const giveawayResults = buildGiveawayResultsFromMedia(media); if (giveawayResults) return { giveawayResults }; const paidMedia = buildPaidMedia(media); @@ -668,7 +672,24 @@ function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined { }; } -function buildGiweawayFromMedia(media: GramJs.TypeMessageMedia): ApiGiveaway | undefined { +function buildDiceFromMedia(media: GramJs.TypeMessageMedia): ApiDice | undefined { + if (!(media instanceof GramJs.MessageMediaDice)) { + return undefined; + } + + return buildDice(media); +} + +function buildDice(media: GramJs.MessageMediaDice): ApiDice | undefined { + const { value, emoticon } = media; + return { + mediaType: 'dice', + value, + emoticon, + }; +} + +function buildGiveawayFromMedia(media: GramJs.TypeMessageMedia): ApiGiveaway | undefined { if (!(media instanceof GramJs.MessageMediaGiveaway)) { return undefined; } @@ -696,7 +717,7 @@ function buildGiveaway(media: GramJs.MessageMediaGiveaway): ApiGiveaway | undefi }; } -function buildGiweawayResultsFromMedia(media: GramJs.TypeMessageMedia): ApiGiveawayResults | undefined { +function buildGiveawayResultsFromMedia(media: GramJs.TypeMessageMedia): ApiGiveawayResults | undefined { if (!(media instanceof GramJs.MessageMediaGiveawayResults)) { return undefined; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index b23c4b2bc..7e4c9435c 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -4,6 +4,7 @@ import type { ApiAttachment, ApiChat, ApiContact, + ApiDice, ApiDraft, ApiFactCheck, ApiInputMessageReplyInfo, @@ -428,29 +429,53 @@ function buildNewTodo(todo: ApiNewMediaTodo): ApiMediaTodo { }; } -export function buildLocalMessage( - chat: ApiChat, - lastMessageId?: number, - text?: string, - entities?: ApiMessageEntity[], - replyInfo?: ApiInputReplyInfo, - suggestedPostInfo?: ApiInputSuggestedPostInfo, - attachment?: ApiAttachment, - sticker?: ApiSticker, - gif?: ApiVideo, - poll?: ApiNewPoll, - todo?: ApiNewMediaTodo, - contact?: ApiContact, - groupedId?: string, - scheduledAt?: number, - scheduleRepeatPeriod?: number, - sendAs?: ApiPeer, - story?: ApiStory | ApiStorySkipped, - isInvertedMedia?: true, - effectId?: string, - isPending?: true, - messagePriceInStars?: number, -) { +export function buildLocalMessage({ + chat, + lastMessageId, + text, + entities, + replyInfo, + suggestedPostInfo, + attachment, + sticker, + gif, + poll, + todo, + contact, + groupedId, + scheduledAt, + scheduleRepeatPeriod, + sendAs, + story, + isInvertedMedia, + effectId, + isPending, + messagePriceInStars, + dice, +}: { + chat: ApiChat; + lastMessageId?: number; + text?: string; + entities?: ApiMessageEntity[]; + replyInfo?: ApiInputReplyInfo; + suggestedPostInfo?: ApiInputSuggestedPostInfo; + attachment?: ApiAttachment; + sticker?: ApiSticker; + gif?: ApiVideo; + poll?: ApiNewPoll; + todo?: ApiNewMediaTodo; + contact?: ApiContact; + groupedId?: string; + scheduledAt?: number; + scheduleRepeatPeriod?: number; + sendAs?: ApiPeer; + story?: ApiStory | ApiStorySkipped; + isInvertedMedia?: true; + effectId?: string; + isPending?: true; + messagePriceInStars?: number; + dice?: string; +}) { const localId = getNextLocalMessageId(lastMessageId); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; @@ -460,7 +485,13 @@ export function buildLocalMessage( const localPoll = poll && buildNewPoll(poll, localId); const localTodo = todo && buildNewTodo(todo); - const formattedText = text ? addTimestampEntities( + const localDice = dice ? { + mediaType: 'dice', + value: -1, + emoticon: dice, + } satisfies ApiDice : undefined; + + const formattedText = text && !dice ? addTimestampEntities( { text, entities, emojiOnlyCount: undefined }, ) : undefined; @@ -476,6 +507,7 @@ export function buildLocalMessage( storyData: story && { mediaType: 'storyData', ...story }, pollId: localPoll?.id, todo: localTodo, + dice: localDice, }), date: scheduledAt || getServerTime(), isOutgoing: !isChannel, diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index ff0d9cbb9..b3c2321cb 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -301,7 +301,7 @@ export function sendMessageLocal( const { chat, lastMessageId, text, entities, replyInfo, suggestedPostInfo, attachment, sticker, story, gif, poll, todo, contact, scheduledAt, scheduleRepeatPeriod, groupedId, sendAs, wasDrafted, isInvertedMedia, effectId, isPending, - messagePriceInStars, + messagePriceInStars, dice, } = params; if (!chat) return undefined; @@ -309,7 +309,7 @@ export function sendMessageLocal( const { message: localMessage, poll: localPoll, - } = buildLocalMessage( + } = buildLocalMessage({ chat, lastMessageId, text, @@ -331,7 +331,8 @@ export function sendMessageLocal( effectId, isPending, messagePriceInStars, - ); + dice, + }); sendApiUpdate({ '@type': localMessage.isScheduled ? 'newScheduledMessage' : 'newMessage', @@ -352,7 +353,7 @@ export function sendApiMessage( ) { const { chat, text, entities, replyInfo, suggestedPostInfo, suggestedMedia, - attachment, sticker, story, gif, poll, todo, contact, + attachment, sticker, story, gif, poll, todo, contact, dice, isSilent, scheduledAt, scheduleRepeatPeriod, groupedId, noWebPage, sendAs, shouldUpdateStickerSetOrder, isInvertedMedia, effectId, webPageMediaSize, webPageUrl, messagePriceInStars, @@ -465,6 +466,10 @@ export function sendApiMessage( lastName: contact.lastName, vcard: DEFAULT_PRIMITIVES.STRING, }); + } else if (dice) { + media = new GramJs.InputMediaDice({ + emoticon: dice, + }); } type SharedKeys = { diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index 98b8cb830..2eef9172d 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -277,6 +277,27 @@ export async function fetchPremiumGifts() { }; } +export async function fetchDiceStickers({ emoji }: { emoji: string }) { + const result = await invokeRequest(new GramJs.messages.GetStickerSet({ + stickerset: new GramJs.InputStickerSetDice({ + emoticon: emoji, + }), + hash: DEFAULT_PRIMITIVES.INT, + })); + + if (!(result instanceof GramJs.messages.StickerSet)) { + return undefined; + } + + localDb.stickerSets[String(result.set.id)] = result.set; + + return { + set: buildStickerSet(result.set), + stickers: processStickerResult(result.documents), + packs: processStickerPackResult(result.packs), + }; +} + export async function fetchTonGifts() { const result = await invokeRequest(new GramJs.messages.GetStickerSet({ stickerset: new GramJs.InputStickerSetTonGifts(), diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 0a62550e8..b42613bd4 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -304,6 +304,12 @@ export type ApiGame = { document?: ApiDocument; }; +export type ApiDice = { + mediaType: 'dice'; + value: number; + emoticon: string; +}; + export type ApiGiveaway = { mediaType: 'giveaway'; quantity: number; @@ -608,6 +614,7 @@ export type MediaContent = { giveaway?: ApiGiveaway; giveawayResults?: ApiGiveawayResults; paidMedia?: ApiPaidMedia; + dice?: ApiDice; ttlSeconds?: number; }; export type MediaContainer = { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index d16259d2e..bc3c31f48 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -1,9 +1,7 @@ -import type { TeactNode } from '../../lib/teact/teact'; - import type { CallbackAction } from '../../global/types'; import type { IconName } from '../../types/icons'; -import type { RegularLangFnParameters } from '../../util/localization'; -import type { ApiDocument, ApiFormattedText, ApiPhoto, ApiReaction } from './messages'; +import type { LangFnParameters, RegularLangFnParameters } from '../../util/localization'; +import type { ApiDocument, ApiFormattedText, ApiMessageEntity, ApiPhoto, ApiReaction } from './messages'; import type { ApiPremiumSection } from './payments'; import type { ApiBotVerification } from './peers'; import type { ApiStarsSubscriptionPricing } from './stars'; @@ -123,8 +121,7 @@ export type ApiNotification = { localId: string; containerSelector?: string; type?: 'paidMessage' | undefined; - title?: string | RegularLangFnParameters; - message: TeactNode | RegularLangFnParameters; + title?: string | LangFnParameters; cacheBreaker?: string; actionText?: string | RegularLangFnParameters; action?: CallbackAction | CallbackAction[]; @@ -136,7 +133,13 @@ export type ApiNotification = { customEmojiIconId?: string; shouldUseCustomIcon?: boolean; dismissAction?: CallbackAction; -}; +} & ({ + message: string; + messageEntities?: ApiMessageEntity[]; +} | { + message: LangFnParameters; + messageEntities?: undefined; +}); export type ApiError = { message: string; @@ -289,6 +292,11 @@ export interface ApiAppConfig { whitelistedBotIds?: string[]; arePasskeysAvailable: boolean; passkeysMaxCount: number; + diceEmojies: string[]; + diceEmojiesSuccess: Record; } export interface ApiConfig { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index e807b833e..713e50d3f 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2557,6 +2557,8 @@ "BotReadTextFromClipboardTitle" = "Clipboard Access"; "BotReadTextFromClipboardDescription" = "{bot} wants to read the contents of your clipboard. Do you want to continue?"; "BotReadTextFromClipboardConfirm" = "Allow"; +"DiceToast" = "Send a {emoji} emoji to try your luck."; +"DiceToastSend" = "Send"; "ChatTypePrivate" = "Private Chat"; "ChatTypeGroup" = "Group"; "ChatTypeChannel" = "Channel"; diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index fa2a3974e..5d8d5582a 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -1,4 +1,4 @@ -import type { ElementRef, FC } from '../../lib/teact/teact'; +import type { ElementRef } from '../../lib/teact/teact'; import { getIsHeavyAnimating, memo, @@ -39,6 +39,7 @@ export type OwnProps = { tgsUrl?: string; play?: boolean | string; playSegment?: [number, number]; + seekToEnd?: boolean; speed?: number; noLoop?: boolean; size: number; @@ -55,11 +56,12 @@ export type OwnProps = { onLoad?: NoneToVoidFunction; onEnded?: NoneToVoidFunction; onLoop?: NoneToVoidFunction; + onFrame?: (index: number) => void; }; const THROTTLE_MS = 150; -const AnimatedSticker: FC = ({ +const AnimatedSticker = ({ ref, renderId, className, @@ -68,6 +70,7 @@ const AnimatedSticker: FC = ({ play, playSegment, speed, + seekToEnd, noLoop, size, quality, @@ -83,7 +86,8 @@ const AnimatedSticker: FC = ({ onLoad, onEnded, onLoop, -}) => { + onFrame, +}: OwnProps) => { let containerRef = useRef(); if (ref) { containerRef = ref; @@ -160,12 +164,17 @@ const AnimatedSticker: FC = ({ onLoad, onEnded, onLoop, + onFrame, ); if (speed) { newAnimation.setSpeed(speed); } + if (seekToEnd) { + newAnimation.seekToEnd(); + } + setAnimation(newAnimation); animationRef.current = newAnimation; }); @@ -207,6 +216,8 @@ const AnimatedSticker: FC = ({ if (playSegmentRef.current) { animation.playSegment(playSegmentRef.current, shouldRestart, viewId); + } else if (seekToEnd) { + animation.seekToEnd(); } else { animation.play(shouldRestart, viewId); } diff --git a/src/components/common/StickerView.tsx b/src/components/common/StickerView.tsx index 8d6c77b8d..cc52a420c 100644 --- a/src/components/common/StickerView.tsx +++ b/src/components/common/StickerView.tsx @@ -1,4 +1,4 @@ -import type { ElementRef, FC } from '../../lib/teact/teact'; +import type { ElementRef } from '../../lib/teact/teact'; import { memo, useMemo, useRef } from '../../lib/teact/teact'; import { getGlobal } from '../../global'; @@ -16,6 +16,7 @@ import useColorFilter from '../../hooks/stickers/useColorFilter'; import useCoordsInSharedCanvas from '../../hooks/useCoordsInSharedCanvas'; import useFlag from '../../hooks/useFlag'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; +import useLastCallback from '../../hooks/useLastCallback'; import useMedia from '../../hooks/useMedia'; import useMediaTransition from '../../hooks/useMediaTransition'; import useMountAfterHeavyAnimation from '../../hooks/useMountAfterHeavyAnimation'; @@ -39,24 +40,28 @@ type OwnProps = { loopLimit?: number; shouldLoop?: boolean; shouldPreloadPreview?: boolean; + skipPreview?: boolean; forceAlways?: boolean; forceOnHeavyAnimation?: boolean; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; noLoad?: boolean; noPlay?: boolean; + forceAnimatedStickerOnEnd?: boolean; noVideoOnMobile?: boolean; withSharedAnimation?: boolean; sharedCanvasRef?: ElementRef; withTranslucentThumb?: boolean; // With shared canvas thumbs are opaque by default to provide better transition effect onVideoEnded?: AnyToVoidFunction; onAnimatedStickerLoop?: AnyToVoidFunction; + onAnimatedStickerFrame?: (frame: number) => void; + onAnimatedStickerLoad?: AnyToVoidFunction; }; const SHARED_PREFIX = 'shared'; const STICKER_SIZE = 24; -const StickerView: FC = ({ +const StickerView = ({ containerRef, sticker, thumbClassName, @@ -68,6 +73,7 @@ const StickerView: FC = ({ loopLimit, shouldLoop = false, shouldPreloadPreview, + skipPreview, forceAlways, forceOnHeavyAnimation, observeIntersectionForLoading, @@ -75,12 +81,15 @@ const StickerView: FC = ({ noLoad, noPlay, noVideoOnMobile, + forceAnimatedStickerOnEnd, withSharedAnimation, withTranslucentThumb, sharedCanvasRef, onVideoEnded, onAnimatedStickerLoop, -}) => { + onAnimatedStickerFrame, + onAnimatedStickerLoad, +}: OwnProps) => { const { id, isLottie, stickerSetInfo, emoji, } = sticker; @@ -111,10 +120,11 @@ const StickerView: FC = ({ const cachedPreview = mediaLoader.getFromMemory(previewMediaHash); const isReadyToMountFullMedia = useMountAfterHeavyAnimation(hasIntersectedForPlayingRef.current); - const shouldForcePreview = isUnsupportedVideo || (isStatic ? isSmall : noPlay); - const shouldLoadPreview = !customColor && !cachedPreview && (!isReadyToMountFullMedia || shouldForcePreview); + const shouldForcePreview = !skipPreview && (isUnsupportedVideo || (isStatic ? isSmall : noPlay)); + const shouldLoadPreview = !skipPreview && !customColor && !cachedPreview + && (!isReadyToMountFullMedia || shouldForcePreview); const previewMediaData = useMedia(previewMediaHash, !shouldLoadPreview); - const withPreview = shouldLoadPreview || cachedPreview; + const withPreview = !skipPreview && (shouldLoadPreview || cachedPreview); const shouldSkipLoadingFullMedia = Boolean(shouldForcePreview || ( fullMediaHash === previewMediaHash && (cachedPreview || previewMediaData) @@ -125,7 +135,7 @@ const StickerView: FC = ({ const isFullMediaReady = shouldRenderFullMedia && (isStatic || isPlayerReady); const thumbDataUri = useThumbnail(sticker.thumbnail); - const thumbData = cachedPreview || previewMediaData || thumbDataUri; + const thumbData = !skipPreview ? (cachedPreview || previewMediaData || thumbDataUri) : undefined; const isThumbOpaque = sharedCanvasRef && !withTranslucentThumb; const noCrossTransition = Boolean(isLottie && withPreview); @@ -153,6 +163,11 @@ const StickerView: FC = ({ ].filter(Boolean).join('_') ), [id, size, customColor, dpr, withSharedAnimation, randomIdPrefix]); + const handleAnimatedStickerLoad = useLastCallback(() => { + onAnimatedStickerLoad?.(); + markPlayerReady(); + }); + return ( <> = ({ )} tgsUrl={fullMediaData} play={shouldPlay} + seekToEnd={forceAnimatedStickerOnEnd} noLoop={!shouldLoop} forceOnHeavyAnimation={forceAlways || forceOnHeavyAnimation} forceAlways={forceAlways} isLowPriority={isSmall && !selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSetInfo)} sharedCanvas={sharedCanvasRef?.current || undefined} sharedCanvasCoords={coords} - onLoad={markPlayerReady} + onLoad={handleAnimatedStickerLoad} onLoop={onAnimatedStickerLoop} onEnded={onAnimatedStickerLoop} + onFrame={onAnimatedStickerFrame} color={customColor} /> ) : isVideo ? ( diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index a93229b15..a4a744ade 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -146,6 +146,7 @@ type StateProps = { isAccountFrozen?: boolean; isAppConfigLoaded?: boolean; isFoldersSidebarShown: boolean; + diceEmojies?: string[]; selectedGiftAuction?: ApiStarGiftAuctionState; }; @@ -200,6 +201,7 @@ const Main = ({ isAccountFrozen, isAppConfigLoaded, isFoldersSidebarShown, + diceEmojies, selectedGiftAuction, }: OwnProps & StateProps) => { const { @@ -216,6 +218,7 @@ const Main = ({ loadCountryList, loadAvailableReactions, loadStickerSets, + loadDiceStickers, loadPremiumGifts, loadTonGifts, loadStarGifts, @@ -390,6 +393,12 @@ const Main = ({ } }, [addedSetIds, addedCustomEmojiIds, isMasterTab, isSynced, isAppConfigLoaded, isAccountFrozen]); + useEffect(() => { + if (isMasterTab && isSynced && isAppConfigLoaded && !isAccountFrozen && diceEmojies) { + loadDiceStickers(); + } + }, [isMasterTab, isSynced, isAppConfigLoaded, isAccountFrozen, diceEmojies]); + useEffect(() => { loadBotFreezeAppeal(); }, [isAppConfigLoaded]); @@ -708,6 +717,7 @@ export default memo(withGlobal( isAccountFrozen, isAppConfigLoaded: global.isAppConfigLoaded, isFoldersSidebarShown: foldersPosition === FOLDERS_POSITION_LEFT && !isMobile && selectAreFoldersPresent(global), + diceEmojies: global.appConfig?.diceEmojies, selectedGiftAuction, }; }, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index d1b99bb65..8b1cb7199 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -181,6 +181,7 @@ import AnimatedEmoji from './AnimatedEmoji'; import CommentButton from './CommentButton'; import Contact from './Contact'; import ContextMenuContainer from './ContextMenuContainer.async'; +import DiceWrapper from './dice/DiceWrapper'; import FactCheck from './FactCheck'; import Game from './Game'; import Giveaway from './Giveaway'; @@ -477,6 +478,7 @@ const Message = ({ const [isPlayingSnapAnimation, setIsPlayingSnapAnimation] = useState(false); const [isPlayingDeleteAnimation, setIsPlayingDeleteAnimation] = useState(false); const [shouldPlayEffect, requestEffect, hideEffect] = useFlag(); + const [shouldPlayDiceEffect, requestDiceEffect, hideDiceEffect] = useFlag(); const [isDeclineDialogOpen, openDeclineDialog, closeDeclineDialog] = useFlag(); const [declineReason, setDeclineReason] = useState(''); const { isMobile, isTouchScreen } = useAppLayout(); @@ -547,7 +549,7 @@ const Message = ({ voice, document, sticker, contact, invoice, location, action, game, storyData, giveaway, - giveawayResults, todo, + giveawayResults, todo, dice, } = getMessageContent(message); const messageReplyInfo = getMessageReplyInfo(message); @@ -780,6 +782,14 @@ const Message = ({ } }, [effect, isLocal, memoFirstUnreadIdRef, messageId, sticker?.hasEffect]); + useEffect(() => { + if (dice && (( + memoFirstUnreadIdRef?.current && messageId >= memoFirstUnreadIdRef.current + ) || isLocal)) { + requestDiceEffect(); + } + }, [dice, memoFirstUnreadIdRef, messageId, isLocal]); + const detectedLanguage = useTextLanguage( text?.text, !(areTranslationsEnabled && shouldDetectChatLanguage) || isTypingDraft, @@ -1299,6 +1309,15 @@ const Message = ({ canAutoLoadMedia={canAutoLoadMedia} /> )} + {dice && ( + + )} {invoice?.extendedMedia && ( = ({ const showSolution = useLastCallback(() => { showNotification({ localId: getMessageKey(message), - message: renderTextWithEntities({ text: poll.results.solution!, entities: poll.results.solutionEntities }), + message: poll.results.solution!, + messageEntities: poll.results.solutionEntities, duration: SOLUTION_DURATION, containerSelector: SOLUTION_CONTAINER_ID, }); diff --git a/src/components/middle/message/dice/Dice.module.scss b/src/components/middle/message/dice/Dice.module.scss new file mode 100644 index 000000000..0d1bebe74 --- /dev/null +++ b/src/components/middle/message/dice/Dice.module.scss @@ -0,0 +1,19 @@ +.root { + cursor: var(--custom-cursor, pointer); + + position: relative; + + display: block !important; + + width: var(--_size); + height: var(--_size); +} + +.sticker { + position: absolute; + inset: 0; +} + +.hidden { + visibility: hidden; +} diff --git a/src/components/middle/message/dice/Dice.tsx b/src/components/middle/message/dice/Dice.tsx new file mode 100644 index 000000000..721027b60 --- /dev/null +++ b/src/components/middle/message/dice/Dice.tsx @@ -0,0 +1,141 @@ +import { memo, useRef, useState } from '@teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiSticker } from '../../../../api/types'; +import type { OwnProps } from './DiceWrapper'; + +import { selectDiceSticker, selectIdleDiceSticker } from '../../../../global/selectors/symbols'; +import buildClassName from '../../../../util/buildClassName'; +import { getStickerDimensions, REM } from '../../../common/helpers/mediaDimensions'; + +import useAppLayout from '../../../../hooks/useAppLayout'; +import useFlag from '../../../../hooks/useFlag'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import StickerView from '../../../common/StickerView'; + +import styles from './Dice.module.scss'; + +type StateProps = { + idleSticker?: ApiSticker; + valueSticker?: ApiSticker; + winEffect?: { + value: number; + frameStart: number; + }; +}; + +const FALLBACK_SIZE = 13 * REM; + +const Dice = ({ + dice, + idleSticker, + valueSticker, + winEffect, + canPlayWinEffect, + isLocal, + isOutgoing, + onEffectPlayed, + observeIntersectionForLoading, + observeIntersectionForPlaying, +}: OwnProps & StateProps) => { + const { requestConfetti, showNotification } = getActions(); + const { isMobile } = useAppLayout(); + const { width } = idleSticker ? getStickerDimensions(idleSticker, isMobile) : { width: FALLBACK_SIZE }; + + const shouldSkipToEnd = !canPlayWinEffect && !isLocal; + const [isShowingResult, setIsShowingResult] = useState(shouldSkipToEnd); + const [isValueStickerLoaded, markValueStickerLoaded] = useFlag(); + + const idleContainerRef = useRef(); + const valueContainerRef = useRef(); + + const onIdleLoop = useLastCallback(() => { + setIsShowingResult(isValueStickerLoaded); + }); + + const onValueFrame = useLastCallback((frame: number) => { + if (canPlayWinEffect && isOutgoing && dice.value === winEffect?.value && frame === winEffect?.frameStart) { + requestConfetti({}); + onEffectPlayed?.(); + } + }); + + const handleClick = useLastCallback(() => { + showNotification({ + message: { + key: 'DiceToast', + variables: { + emoji: dice.emoticon, + }, + options: { + withNodes: true, + }, + }, + action: { + action: 'sendDiceInCurrentChat', + payload: { + emoji: dice.emoticon, + }, + }, + actionText: { + key: 'DiceToastSend', + }, + }); + }); + + return ( +
+ {idleSticker && ( +
+ +
+ )} + {valueSticker && ( +
+ +
+ )} +
+ ); +}; + +export default memo(withGlobal( + (global, { dice }): Complete => { + const idleSticker = selectIdleDiceSticker(global, dice.emoticon); + const valueSticker = selectDiceSticker(global, dice.emoticon, dice.value); + + const winEffect = global.appConfig.diceEmojiesSuccess[dice.emoticon]; + return { + idleSticker, + valueSticker, + winEffect, + }; + }, +)(Dice)); diff --git a/src/components/middle/message/dice/DiceWrapper.tsx b/src/components/middle/message/dice/DiceWrapper.tsx new file mode 100644 index 000000000..7af8e81d4 --- /dev/null +++ b/src/components/middle/message/dice/DiceWrapper.tsx @@ -0,0 +1,29 @@ +import { memo } from '../../../../lib/teact/teact'; + +import type { ApiDice } from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; + +import { SLOT_MACHINE_EMOJI } from '../../../../config'; + +import Dice from './Dice'; +import SlotMachine from './SlotMachine'; + +export type OwnProps = { + dice: ApiDice; + canPlayWinEffect?: boolean; + isLocal?: boolean; + isOutgoing?: boolean; + onEffectPlayed?: NoneToVoidFunction; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; +}; + +const DiceWrapper = (props: OwnProps) => { + if (props.dice.emoticon === SLOT_MACHINE_EMOJI) { + return ; + } + + return ; +}; + +export default memo(DiceWrapper); diff --git a/src/components/middle/message/dice/SlotMachine.tsx b/src/components/middle/message/dice/SlotMachine.tsx new file mode 100644 index 000000000..55663df1a --- /dev/null +++ b/src/components/middle/message/dice/SlotMachine.tsx @@ -0,0 +1,202 @@ +import { memo, useRef, useState } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiStickerSet } from '../../../../api/types'; +import type { OwnProps } from './DiceWrapper'; + +import { SLOT_MACHINE_EMOJI } from '../../../../config'; +import { getStickerMediaHash } from '../../../../global/helpers'; +import { selectStickerSet } from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import { getStickerDimensions, REM } from '../../../common/helpers/mediaDimensions'; +import { prepareSlotMachine } from '../helpers/prepareSlotMachine'; + +import useAppLayout from '../../../../hooks/useAppLayout'; +import useFlag from '../../../../hooks/useFlag'; +import { useIsIntersecting } from '../../../../hooks/useIntersectionObserver'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import useMedia from '../../../../hooks/useMedia'; +import useMediaTransition from '../../../../hooks/useMediaTransition'; + +import AnimatedSticker from '../../../common/AnimatedSticker'; + +import styles from './Dice.module.scss'; + +type StateProps = { + slotsStickerSet?: ApiStickerSet; + winEffect?: { + value: number; + frameStart: number; + }; +}; + +const FALLBACK_SIZE = 13 * REM; +const STICKER_RENDER_DELAY = 100; +const WIN_BACKGROUND_DELAY = 700; + +const SlotMachine = ({ + dice, + canPlayWinEffect, + isLocal, + isOutgoing, + slotsStickerSet, + winEffect, + onEffectPlayed, + observeIntersectionForLoading, +}: OwnProps & StateProps) => { + const { requestConfetti, showNotification } = getActions(); + const { isMobile } = useAppLayout(); + + const isWin = dice.value === winEffect?.value; + const shouldSkipToEnd = !canPlayWinEffect && !isLocal; + + const loadedCountRef = useRef(0); + const [isReady, markReady] = useFlag(!shouldSkipToEnd); + + const [backgroundState, setBackgroundState] = useState<'base' | 'win'>(shouldSkipToEnd && isWin ? 'win' : 'base'); + const [spinState, setSpinState] = useState<'base' | 'result'>(shouldSkipToEnd ? 'result' : 'base'); + + const { ref } = useMediaTransition({ + hasMediaData: isReady, + }); + + const canLoad = useIsIntersecting(ref, observeIntersectionForLoading); + + const preparedStickers = slotsStickerSet?.stickers && prepareSlotMachine(slotsStickerSet?.stickers, dice.value); + const backgroundHash = preparedStickers?.background + ? getStickerMediaHash(preparedStickers.background, 'full') : undefined; + const backgroundData = useMedia(backgroundHash, !canLoad); + const frameWinHash = preparedStickers?.frameWin && isWin + ? getStickerMediaHash(preparedStickers.frameWin, 'full') : undefined; + const frameWinData = useMedia(frameWinHash, !canLoad); + const frameStartHash = preparedStickers?.frameStart + ? getStickerMediaHash(preparedStickers.frameStart, 'full') : undefined; + const frameStartData = useMedia(frameStartHash, !canLoad); + + const leftSpinHash = preparedStickers?.leftSpin + ? getStickerMediaHash(preparedStickers.leftSpin, 'full') : undefined; + const leftSpinData = useMedia(leftSpinHash, !canLoad); + const middleSpinHash = preparedStickers?.middleSpin + ? getStickerMediaHash(preparedStickers.middleSpin, 'full') : undefined; + const middleSpinData = useMedia(middleSpinHash, !canLoad); + const rightSpinHash = preparedStickers?.rightSpin + ? getStickerMediaHash(preparedStickers.rightSpin, 'full') : undefined; + const rightSpinData = useMedia(rightSpinHash, !canLoad); + + const leftResultHash = preparedStickers?.leftResult + ? getStickerMediaHash(preparedStickers.leftResult, 'full') : undefined; + const leftResultData = useMedia(leftResultHash, !canLoad); + const middleResultHash = preparedStickers?.middleResult + ? getStickerMediaHash(preparedStickers.middleResult, 'full') : undefined; + const middleResultData = useMedia(middleResultHash, !canLoad); + const rightResultHash = preparedStickers?.rightResult + ? getStickerMediaHash(preparedStickers.rightResult, 'full') : undefined; + const rightResultData = useMedia(rightResultHash, !canLoad); + + const { width } = preparedStickers ? getStickerDimensions(preparedStickers.background, isMobile) + : { width: FALLBACK_SIZE }; + + const isWaitingForResults = !leftResultData || !middleResultData || !rightResultData; + + const handleLoaded = useLastCallback(() => { + loadedCountRef.current += 1; + if (loadedCountRef.current >= 3) { + setTimeout(() => { + markReady(); + }, STICKER_RENDER_DELAY); + } + }); + + const handleSpinEnded = useLastCallback(() => { + if (isWaitingForResults) return; + setSpinState('result'); + // Result spin start - too early. Result spin end - too late. + if (isWin) setTimeout(() => setBackgroundState('win'), WIN_BACKGROUND_DELAY); + }); + + const onWinBackgroundFrame = useLastCallback((frame: number) => { + if (canPlayWinEffect && isOutgoing && isWin && frame === winEffect?.frameStart) { + requestConfetti({}); + onEffectPlayed?.(); + } + }); + + const handleClick = useLastCallback(() => { + showNotification({ + message: { + key: 'DiceToast', + variables: { + emoji: dice.emoticon, + }, + options: { + withNodes: true, + }, + }, + action: { + action: 'sendDiceInCurrentChat', + payload: { + emoji: dice.emoticon, + }, + }, + actionText: { + key: 'DiceToastSend', + }, + }); + }); + + function renderSticker( + tgsUrl: string | undefined, + isHidden: boolean, + shouldLoop?: boolean, + noRenderOnHidden?: boolean, + onEnded?: NoneToVoidFunction, + onFrame?: (frame: number) => void, + ) { + if (noRenderOnHidden && isHidden) return undefined; + return ( +
+ +
+ ); + } + + return ( +
+ {renderSticker(backgroundData, backgroundState !== 'base', false, true)} + {renderSticker(frameWinData, backgroundState !== 'win', false, false, undefined, onWinBackgroundFrame)} + + {renderSticker(leftSpinData, spinState !== 'base', isWaitingForResults, true)} + {renderSticker(middleSpinData, spinState !== 'base', isWaitingForResults, true)} + {renderSticker(rightSpinData, spinState !== 'base', isWaitingForResults, true, handleSpinEnded)} + + {renderSticker(leftResultData, spinState !== 'result')} + {renderSticker(middleResultData, spinState !== 'result')} + {renderSticker(rightResultData, spinState !== 'result')} + + {renderSticker(frameStartData, false)} +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + const stickerSetId = global.stickers.diceSetIdByEmoji?.[SLOT_MACHINE_EMOJI]; + const slotsStickerSet = stickerSetId ? selectStickerSet(global, stickerSetId) : undefined; + const winEffect = global.appConfig.diceEmojiesSuccess[SLOT_MACHINE_EMOJI]; + + return { + slotsStickerSet, + winEffect, + }; + }, +)(SlotMachine)); diff --git a/src/components/middle/message/helpers/messageActions.tsx b/src/components/middle/message/helpers/messageActions.tsx index 7fc159791..f7df52c82 100644 --- a/src/components/middle/message/helpers/messageActions.tsx +++ b/src/components/middle/message/helpers/messageActions.tsx @@ -58,7 +58,7 @@ export function translateWithYou( export function getPinnedMediaValue(lang: LangFn, message: ApiMessage) { const { audio, contact, document, game, giveaway, giveawayResults, paidMedia, storyData, - invoice, location, photo, pollId, sticker, video, voice, + invoice, location, photo, pollId, sticker, video, voice, dice, } = getMessageContent(message); if (message.groupedId || paidMedia) return lang('ActionPinnedMediaAlbum'); @@ -78,6 +78,7 @@ export function getPinnedMediaValue(lang: LangFn, message: ApiMessage) { if (pollId) return lang('ActionPinnedMediaPoll'); if (giveaway) return lang('ActionPinnedMediaGiveaway'); if (giveawayResults) return lang('ActionPinnedMediaGiveawayResults'); + if (dice) return dice.emoticon; return undefined; } diff --git a/src/components/middle/message/helpers/prepareSlotMachine.ts b/src/components/middle/message/helpers/prepareSlotMachine.ts new file mode 100644 index 000000000..9567078a5 --- /dev/null +++ b/src/components/middle/message/helpers/prepareSlotMachine.ts @@ -0,0 +1,41 @@ +import type { ApiSticker } from '../../../../api/types'; + +const SLOT_MAP = [1, 2, 3, 0]; +export function prepareSlotMachine(stickers: ApiSticker[], value: number) { + const isLocal = value === -1; + + const leftSlot = (value - 1) & 0b11; + const middleSlot = ((value - 1) >> 2) & 0b11; + const rightSlot = ((value - 1) >> 4) & 0b11; + + const bg = stickers[0]; + const frameWin = stickers[1]; + const frameStart = stickers[2]; + + const leftWin = stickers[3]; + const leftResult = !isLocal ? stickers[4 + SLOT_MAP[leftSlot]] : undefined; + const leftSpin = stickers[8]; + + const middleWin = stickers[9]; + const middleResult = !isLocal ? stickers[10 + SLOT_MAP[middleSlot]] : undefined; + const middleSpin = stickers[14]; + + const rightWin = stickers[15]; + const rightResult = !isLocal ? stickers[16 + SLOT_MAP[rightSlot]] : undefined; + const rightSpin = stickers[20]; + + return { + background: bg, + frameWin, + frameStart, + leftWin, + leftResult, + leftSpin, + middleWin, + middleResult, + middleSpin, + rightWin, + rightResult, + rightSpin, + }; +} diff --git a/src/components/modals/gift/GiftItemStar.tsx b/src/components/modals/gift/GiftItemStar.tsx index 386a14e93..124ecb561 100644 --- a/src/components/modals/gift/GiftItemStar.tsx +++ b/src/components/modals/gift/GiftItemStar.tsx @@ -99,13 +99,17 @@ function GiftItemStar({ if (isUserLimitReached) { showNotification({ - message: lang('NotificationGiftsLimit2', { - count: perUserTotal, - }, { - pluralValue: perUserTotal!, - withMarkdown: true, - withNodes: true, - }), + message: { + key: 'NotificationGiftsLimit2', + variables: { + count: perUserTotal, + }, + options: { + pluralValue: perUserTotal!, + withMarkdown: true, + withNodes: true, + }, + }, }); return; } diff --git a/src/components/ui/Notification.tsx b/src/components/ui/Notification.tsx index 84f280f60..fd67c9704 100644 --- a/src/components/ui/Notification.tsx +++ b/src/components/ui/Notification.tsx @@ -1,4 +1,3 @@ -import type { FC } from '../../lib/teact/teact'; import { useEffect, useMemo, @@ -15,6 +14,7 @@ import buildClassName from '../../util/buildClassName'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import { REM } from '../common/helpers/mediaDimensions'; import renderText from '../common/helpers/renderText'; +import { renderTextWithEntities } from '../common/helpers/renderTextWithEntities'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; @@ -37,9 +37,9 @@ const DEFAULT_DURATION = 3000; const ANIMATION_DURATION = 150; const CUSTOM_EMOJI_SIZE = 1.75 * REM; -const Notification: FC = ({ +const Notification = ({ notification, -}) => { +}: OwnProps) => { const actions = getActions(); const lang = useLang(); @@ -62,7 +62,10 @@ const Notification: FC = ({ containerSelector, } = notification; + const isMessageLangFnParam = isLangFnParam(message); + const [isOpen, setIsOpen] = useState(true); + const actionActivationRef = useRef(false); const timerRef = useRef(); const { transitionClassNames } = useShowTransitionDeprecated(isOpen); @@ -80,8 +83,9 @@ const Notification: FC = ({ } }); - const handleActionClick = useLastCallback(() => { - if (action) { + const handleActionClick = useLastCallback((e: React.MouseEvent) => { + if (action && !actionActivationRef.current) { + actionActivationRef.current = true; if (Array.isArray(action)) { // @ts-ignore action.forEach((cb) => actions[cb.action](cb.payload)); @@ -98,7 +102,8 @@ const Notification: FC = ({ }); const handleClick = useLastCallback(() => { - if (action) { + if (action && !actionActivationRef.current) { + actionActivationRef.current = true; if (Array.isArray(action)) { // @ts-ignore action.forEach((cb) => actions[cb.action](cb.payload)); @@ -146,19 +151,27 @@ const Notification: FC = ({ } return renderText(title, ['simple_markdown', 'emoji', 'br', 'links']); + // @ts-expect-error -- Lang Parameters are too complex }, [lang, title]); const renderedMessage = useMemo(() => { - if (isLangFnParam(message)) { + if (isMessageLangFnParam) { return lang.with(message); } if (typeof message === 'string') { + if (notification.messageEntities) { + return renderTextWithEntities({ + text: message, + entities: notification.messageEntities, + }); + } return renderText(message, ['simple_markdown', 'emoji', 'br', 'links']); } return message; - }, [lang, message]); + // @ts-expect-error -- Lang Parameters are too complex + }, [isMessageLangFnParam, lang, message, notification.messageEntities]); const renderedActionText = useMemo(() => { if (!actionText) return undefined; diff --git a/src/config.ts b/src/config.ts index 7f854876f..5aa0cf844 100644 --- a/src/config.ts +++ b/src/config.ts @@ -459,3 +459,5 @@ export const DEFAULT_RESALE_GIFTS_FILTER_OPTIONS: ResaleGiftsFilterOptions = { }; export const ACCOUNT_TTL_OPTIONS = [1, 3, 6, 12, 18, 24]; + +export const SLOT_MACHINE_EMOJI = '🎰'; diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 20cb9b47c..54c7295af 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -434,6 +434,12 @@ addActionHandler('sendMessage', async (global, actions, payload): Promise }); } + const diceEmojies = global.appConfig.diceEmojies; + let dice = payload.dice; + if (payload.text && !payload.entities?.length && diceEmojies.includes(payload.text)) { + dice = payload.text; + } + const params: SendMessageParams = { ...payload, chat, @@ -445,6 +451,8 @@ addActionHandler('sendMessage', async (global, actions, payload): Promise lastMessageId, messagePriceInStars, isStoryReply, + dice, + text: !dice ? payload.text : undefined, isPending: messagePriceInStars ? true : undefined, ...suggestedMessage && { isInvertedMedia: suggestedMessage?.isInvertedMedia }, }; @@ -610,6 +618,20 @@ addActionHandler('sendInviteMessages', async (global, actions, payload): Promise }); }); +addActionHandler('sendDiceInCurrentChat', (global, actions, payload): ActionReturnType => { + const { emoji, tabId = getCurrentTabId() } = payload; + const messageList = selectCurrentMessageList(global, tabId); + if (!messageList) { + return undefined; + } + + actions.sendMessage({ + messageList, + dice: emoji, + tabId, + }); +}); + addActionHandler('editMessage', (global, actions, payload): ActionReturnType => { const { messageList, text, entities, attachments, tabId = getCurrentTabId(), diff --git a/src/global/actions/api/symbols.ts b/src/global/actions/api/symbols.ts index 047f67317..9eedebaba 100644 --- a/src/global/actions/api/symbols.ts +++ b/src/global/actions/api/symbols.ts @@ -199,6 +199,34 @@ addActionHandler('loadFeaturedStickers', async (global): Promise => { setGlobal(global); }); +addActionHandler('loadDiceStickers', async (global): Promise => { + const emojis = global.appConfig.diceEmojies; + const promises = emojis.map((emoji) => callApi('fetchDiceStickers', { emoji })); + const results = await Promise.all(promises); + + global = getGlobal(); + results.forEach((result, index) => { + if (!result) { + return; + } + const emoji = emojis[index]; + const { set, stickers, packs } = result; + global = updateStickerSet(global, set.id, { ...set, stickers, packs }); + global = { + ...global, + stickers: { + ...global.stickers, + diceSetIdByEmoji: { + ...global.stickers.diceSetIdByEmoji, + [emoji]: set.id, + }, + }, + }; + }); + + setGlobal(global); +}); + addActionHandler('loadPremiumGifts', async (global): Promise => { const stickerSet = await callApi('fetchPremiumGifts'); if (!stickerSet) { diff --git a/src/global/cache.ts b/src/global/cache.ts index 062d5f3bc..5c5060900 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -443,6 +443,7 @@ function reduceGlobal(global: T) { 'availableEffectById', ]), lastIsChatInfoShown: !getIsMobile() ? global.lastIsChatInfoShown : undefined, + stickers: reduceStickers(global), customEmojis: reduceCustomEmojis(global), users: reduceUsers(global), chats: reduceChats(global), @@ -486,6 +487,15 @@ export function serializeGlobal(global: T) { return JSON.stringify(reduceGlobal(global)); } +function reduceStickers(global: T): GlobalState['stickers'] { + const { diceSetIdByEmoji, setsById } = global.stickers; + return { + ...INITIAL_GLOBAL_STATE.stickers, + diceSetIdByEmoji, + setsById: pickTruthy(setsById, Object.values(diceSetIdByEmoji || {})), + }; +} + function reduceCustomEmojis(global: T): GlobalState['customEmojis'] { const { lastRendered, byId } = global.customEmojis; const folderEmojiIds = Object.values(global.chatFolders.byId) diff --git a/src/global/helpers/messageSummary.ts b/src/global/helpers/messageSummary.ts index 13fdbabaf..20974b54f 100644 --- a/src/global/helpers/messageSummary.ts +++ b/src/global/helpers/messageSummary.ts @@ -152,6 +152,7 @@ function getSummaryDescription( giveawayResults, paidMedia, todo, + dice, } = mediaContent; const { poll } = statefulContent || {}; @@ -256,6 +257,10 @@ function getSummaryDescription( }); } + if (dice) { + summary = dice.emoticon; + } + return summary || lang('MessageUnsupported'); } diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index fa406d3f3..96286c194 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -63,13 +63,13 @@ export function getMessageTranscription(message: ApiMessage) { export function hasMessageText(message: MediaContainer) { const { - action, text, sticker, photo, video, audio, voice, document, pollId, todo, + action, text, sticker, photo, video, audio, voice, document, pollId, todo, dice, webPage, contact, invoice, location, game, storyData, giveaway, giveawayResults, paidMedia, } = message.content; return Boolean(text) || !( sticker || photo || video || audio || voice || document || contact || pollId || todo || webPage - || invoice || location || game || storyData || giveaway || giveawayResults + || invoice || location || game || storyData || giveaway || giveawayResults || dice || paidMedia || action?.type === 'phoneCall' ); } @@ -112,10 +112,10 @@ export function getMessageCustomShape(message: ApiMessage): boolean { const { text, sticker, photo, video, audio, voice, document, pollId, webPage, contact, action, - game, invoice, location, storyData, + game, invoice, location, storyData, dice, } = message.content; - if (sticker || (video?.isRound)) { + if (sticker || (video?.isRound) || dice) { return true; } diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 14cd5ae3b..c78ef1cb9 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -726,7 +726,7 @@ export function selectAllowedMessageActionsSlow( ) && !( content.sticker || content.contact || content.pollId || content.action || (content.video?.isRound) || content.location || content.invoice || content.giveaway || content.giveawayResults - || isDocumentSticker + || isDocumentSticker || content.dice ) && !isForwarded && !message.viaBotId @@ -763,7 +763,7 @@ export function selectAllowedMessageActionsSlow( const canReport = !isPrivate && !isOwn; const canDeleteForAll = canDelete && !chat.isForbidden && ( - (isPrivate && !isChatWithSelf && !isBotChat) + (isPrivate && !isChatWithSelf && !isBotChat && !content.dice) || (isBasicGroup && ( isOwn || getHasAdminRight(chat, 'deleteMessages') || chat.isCreator )) diff --git a/src/global/selectors/symbols.ts b/src/global/selectors/symbols.ts index f015750c4..4d612979b 100644 --- a/src/global/selectors/symbols.ts +++ b/src/global/selectors/symbols.ts @@ -250,3 +250,28 @@ export function selectGiftStickerForTon(global: T, amount export function selectCustomEmoji(global: T, documentId: string) { return global.customEmojis.byId[documentId]; } + +export function selectIdleDiceSticker(global: T, diceEmoji: string) { + const { diceSetIdByEmoji } = global.stickers; + if (!diceSetIdByEmoji) return undefined; + const diceSetId = diceSetIdByEmoji[diceEmoji]; + if (!diceSetId) return undefined; + + const diceSet = global.stickers.setsById[diceSetId]; + if (!diceSet) return undefined; + + return diceSet.stickers?.find((sticker) => sticker.emoji === '#\u20E3'); +} + +export function selectDiceSticker(global: T, diceEmoji: string, value: number) { + const { diceSetIdByEmoji } = global.stickers; + if (!diceSetIdByEmoji) return undefined; + const diceSetId = diceSetIdByEmoji[diceEmoji]; + if (!diceSetId) return undefined; + + const diceSet = global.stickers.setsById[diceSetId]; + if (!diceSet) return undefined; + + const numberEmoji = `${value}\u20E3`; + return diceSet.stickers?.find((sticker) => sticker.emoji === numberEmoji); +} diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index d5aa7bdcd..614983921 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -495,6 +495,9 @@ export interface ActionPayloads { sendMessages: { sendParams: SendMessageParams[]; }; + sendDiceInCurrentChat: { + emoji: string; + } & WithTabId; sendInviteMessages: { chatId: string; userIds: string[]; @@ -2006,6 +2009,7 @@ export interface ActionPayloads { loadRecentStickers: undefined; loadFavoriteStickers: undefined; loadFeaturedStickers: undefined; + loadDiceStickers: undefined; reorderStickerSets: { isCustomEmoji?: boolean; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index fc915494b..4192206c5 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -366,6 +366,7 @@ export type GlobalState = { stickers: ApiSticker[]; emojis: ApiSticker[]; }; + diceSetIdByEmoji?: Record; }; customEmojis: { diff --git a/src/lib/rlottie/RLottie.ts b/src/lib/rlottie/RLottie.ts index 7e270f852..e604eaacd 100644 --- a/src/lib/rlottie/RLottie.ts +++ b/src/lib/rlottie/RLottie.ts @@ -22,6 +22,8 @@ type Frame = | typeof WAITING | ImageBitmap; +type FrameCallback = (index: number) => void; + 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; @@ -47,6 +49,7 @@ class RLottie { isSharedCanvas?: boolean; coords?: Params['coords']; onLoad?: NoneToVoidFunction; + onFrame?: FrameCallback; }>(); private imgSize!: number; @@ -89,13 +92,19 @@ class RLottie { private lastRenderAt?: number; + private requestedSeekToEnd = false; + static init(...args: ConstructorParameters) { const [ , canvas, renderId, params, - viewId = generateUniqueId(), , + viewId = generateUniqueId(), + , onLoad, + , + , + onFrame, ] = args; let instance = instancesByRenderId.get(renderId); @@ -103,7 +112,7 @@ class RLottie { instance = new RLottie(...args); instancesByRenderId.set(renderId, instance); } else { - instance.addView(viewId, canvas, onLoad, params?.coords); + instance.addView(viewId, canvas, onLoad, onFrame, params?.coords); } return instance; @@ -111,16 +120,17 @@ class RLottie { constructor( private tgsUrl: string, - private container: HTMLDivElement | HTMLCanvasElement, + container: HTMLDivElement | HTMLCanvasElement, private renderId: string, private params: Params, viewId: string = generateUniqueId(), private customColor?: [number, number, number], - private onLoad?: NoneToVoidFunction | undefined, + onLoad?: NoneToVoidFunction | undefined, private onEnded?: (isDestroyed?: boolean) => void, private onLoop?: () => void, + onFrame?: FrameCallback, ) { - this.addView(viewId, container, onLoad, params.coords); + this.addView(viewId, container, onLoad, onFrame, params.coords); this.initConfig(); this.initRenderer(); } @@ -209,6 +219,11 @@ class RLottie { this.doPlay(); } + seekToEnd() { + this.requestedSeekToEnd = true; + this.doPlay(); + } + setSpeed(speed: number) { this.speed = speed; } @@ -257,6 +272,7 @@ class RLottie { viewId: string, container: HTMLDivElement | HTMLCanvasElement, onLoad?: NoneToVoidFunction, + onFrame?: FrameCallback, coords?: Params['coords'], ) { const sizeFactor = this.calcSizeFactor(); @@ -292,7 +308,7 @@ class RLottie { container.appendChild(canvas); this.views.set(viewId, { - canvas, ctx, onLoad, + canvas, ctx, onLoad, onFrame, }); }); } else { @@ -442,6 +458,12 @@ class RLottie { return; } + if (this.requestedSeekToEnd) { + this.approxFrameIndex = this.framesCount - 1; + this.stopFrameIndex = undefined; + this.requestedSeekToEnd = false; + } + if (this.isAnimating) { return; } @@ -486,12 +508,13 @@ class RLottie { if (frameIndex !== this.prevFrameIndex) { this.views.forEach((containerData) => { const { - ctx, isLoaded, isPaused, coords: { x, y } = {}, onLoad, + ctx, isLoaded, isPaused, coords: { x, y } = {}, onLoad, onFrame, } = containerData; if (!isLoaded || !isPaused) { ctx.clearRect(x || 0, y || 0, this.imgSize, this.imgSize); ctx.drawImage(frame, x || 0, y || 0); + onFrame?.(frameIndex); } if (!isLoaded) { diff --git a/src/limits.ts b/src/limits.ts index 742ac2d2c..29e4b192f 100644 --- a/src/limits.ts +++ b/src/limits.ts @@ -160,4 +160,6 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = { typingDraftTtl: 10, arePasskeysAvailable: true, passkeysMaxCount: 5, + diceEmojies: [], + diceEmojiesSuccess: {}, }; diff --git a/src/types/index.ts b/src/types/index.ts index e40d23600..a71592bea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -723,6 +723,7 @@ export type SendMessageParams = { gif?: ApiVideo; poll?: ApiNewPoll; todo?: ApiNewMediaTodo; + dice?: string; contact?: ApiContact; isSilent?: boolean; scheduledAt?: number; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 1dc69a51c..1e5289095 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1885,6 +1885,7 @@ export interface LangPair { 'SettingsBirthday': undefined; 'BotReadTextFromClipboardTitle': undefined; 'BotReadTextFromClipboardConfirm': undefined; + 'DiceToastSend': undefined; 'ChatTypePrivate': undefined; 'ChatTypeGroup': undefined; 'ChatTypeChannel': undefined; @@ -3329,6 +3330,9 @@ export interface LangPairWithVariables { 'BotReadTextFromClipboardDescription': { 'bot': V; }; + 'DiceToast': { + 'emoji': V; + }; 'GroupStatusWithOnline': { 'status': V; 'onlineCount': V;