Support Dice (#6572)

This commit is contained in:
zubiden 2026-01-20 12:00:34 +01:00 committed by Alexander Zinchuk
parent 049db12d92
commit 1c602dddfa
35 changed files with 829 additions and 81 deletions

View File

@ -38,6 +38,10 @@ export interface GramJsAppConfig extends LimitsConfig {
file_reference_base64: string;
}>;
emojies_send_dice: string[];
emojies_send_dice_success: Record<string, {
value: number;
frame_start: number;
}>;
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 {

View File

@ -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;
}

View File

@ -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,

View File

@ -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<T, U> = {

View File

@ -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(),

View File

@ -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 = {

View File

@ -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<string, {
value: number;
frameStart: number;
}>;
}
export interface ApiConfig {

View File

@ -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";

View File

@ -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<OwnProps> = ({
const AnimatedSticker = ({
ref,
renderId,
className,
@ -68,6 +70,7 @@ const AnimatedSticker: FC<OwnProps> = ({
play,
playSegment,
speed,
seekToEnd,
noLoop,
size,
quality,
@ -83,7 +86,8 @@ const AnimatedSticker: FC<OwnProps> = ({
onLoad,
onEnded,
onLoop,
}) => {
onFrame,
}: OwnProps) => {
let containerRef = useRef<HTMLDivElement>();
if (ref) {
containerRef = ref;
@ -160,12 +164,17 @@ const AnimatedSticker: FC<OwnProps> = ({
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<OwnProps> = ({
if (playSegmentRef.current) {
animation.playSegment(playSegmentRef.current, shouldRestart, viewId);
} else if (seekToEnd) {
animation.seekToEnd();
} else {
animation.play(shouldRestart, viewId);
}

View File

@ -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<HTMLCanvasElement>;
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<OwnProps> = ({
const StickerView = ({
containerRef,
sticker,
thumbClassName,
@ -68,6 +73,7 @@ const StickerView: FC<OwnProps> = ({
loopLimit,
shouldLoop = false,
shouldPreloadPreview,
skipPreview,
forceAlways,
forceOnHeavyAnimation,
observeIntersectionForLoading,
@ -75,12 +81,15 @@ const StickerView: FC<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
].filter(Boolean).join('_')
), [id, size, customColor, dpr, withSharedAnimation, randomIdPrefix]);
const handleAnimatedStickerLoad = useLastCallback(() => {
onAnimatedStickerLoad?.();
markPlayerReady();
});
return (
<>
<img
@ -182,15 +197,17 @@ const StickerView: FC<OwnProps> = ({
)}
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 ? (

View File

@ -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<OwnProps>(
isAccountFrozen,
isAppConfigLoaded: global.isAppConfigLoaded,
isFoldersSidebarShown: foldersPosition === FOLDERS_POSITION_LEFT && !isMobile && selectAreFoldersPresent(global),
diceEmojies: global.appConfig?.diceEmojies,
selectedGiftAuction,
};
},

View File

@ -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 && (
<DiceWrapper
isLocal={isLocal}
dice={dice}
isOutgoing={isOwn}
canPlayWinEffect={shouldPlayDiceEffect}
onEffectPlayed={hideDiceEffect}
/>
)}
{invoice?.extendedMedia && (
<InvoiceMediaPreview
message={message}

View File

@ -179,7 +179,8 @@ const Poll: FC<OwnProps> = ({
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,
});

View File

@ -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;
}

View File

@ -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<boolean>(shouldSkipToEnd);
const [isValueStickerLoaded, markValueStickerLoaded] = useFlag();
const idleContainerRef = useRef<HTMLDivElement>();
const valueContainerRef = useRef<HTMLDivElement>();
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 (
<div className={styles.root} style={`--_size: ${width}px`} onClick={handleClick}>
{idleSticker && (
<div
ref={idleContainerRef}
className={buildClassName(styles.sticker, isShowingResult && styles.hidden)}
>
<StickerView
containerRef={idleContainerRef}
sticker={idleSticker}
size={width}
noPlay={isShowingResult}
shouldLoop
forceAlways
skipPreview={isShowingResult}
onAnimatedStickerLoop={onIdleLoop}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
</div>
)}
{valueSticker && (
<div ref={valueContainerRef} className={buildClassName(styles.sticker, !isShowingResult && styles.hidden)}>
<StickerView
containerRef={valueContainerRef}
sticker={valueSticker}
size={width}
noPlay={!isShowingResult}
skipPreview
forceAlways
forceAnimatedStickerOnEnd={shouldSkipToEnd}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onAnimatedStickerLoad={markValueStickerLoaded}
onAnimatedStickerFrame={onValueFrame}
/>
</div>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { dice }): Complete<StateProps> => {
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));

View File

@ -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 <SlotMachine {...props} />;
}
return <Dice {...props} />;
};
export default memo(DiceWrapper);

View File

@ -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 (
<div className={buildClassName(styles.sticker, isHidden && styles.hidden)}>
<AnimatedSticker
tgsUrl={tgsUrl}
size={width}
play={!isHidden}
noLoop={!shouldLoop}
forceAlways
onEnded={onEnded}
onFrame={onFrame}
onLoad={!isHidden ? handleLoaded : undefined}
seekToEnd={shouldSkipToEnd}
/>
</div>
);
}
return (
<div ref={ref} className={styles.root} style={`--_size: ${width}px`} onClick={handleClick}>
{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)}
</div>
);
};
export default memo(withGlobal(
(global): Complete<StateProps> => {
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));

View File

@ -58,7 +58,7 @@ export function translateWithYou<K extends LangKey>(
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;
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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<OwnProps> = ({
const Notification = ({
notification,
}) => {
}: OwnProps) => {
const actions = getActions();
const lang = useLang();
@ -62,7 +62,10 @@ const Notification: FC<OwnProps> = ({
containerSelector,
} = notification;
const isMessageLangFnParam = isLangFnParam(message);
const [isOpen, setIsOpen] = useState(true);
const actionActivationRef = useRef<boolean>(false);
const timerRef = useRef<number | undefined>();
const { transitionClassNames } = useShowTransitionDeprecated(isOpen);
@ -80,8 +83,9 @@ const Notification: FC<OwnProps> = ({
}
});
const handleActionClick = useLastCallback(() => {
if (action) {
const handleActionClick = useLastCallback((e: React.MouseEvent<HTMLButtonElement>) => {
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<OwnProps> = ({
});
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<OwnProps> = ({
}
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;

View File

@ -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 = '🎰';

View File

@ -434,6 +434,12 @@ addActionHandler('sendMessage', async (global, actions, payload): Promise<void>
});
}
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<void>
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(),

View File

@ -199,6 +199,34 @@ addActionHandler('loadFeaturedStickers', async (global): Promise<void> => {
setGlobal(global);
});
addActionHandler('loadDiceStickers', async (global): Promise<void> => {
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<void> => {
const stickerSet = await callApi('fetchPremiumGifts');
if (!stickerSet) {

View File

@ -443,6 +443,7 @@ function reduceGlobal<T extends GlobalState>(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<T extends GlobalState>(global: T) {
return JSON.stringify(reduceGlobal(global));
}
function reduceStickers<T extends GlobalState>(global: T): GlobalState['stickers'] {
const { diceSetIdByEmoji, setsById } = global.stickers;
return {
...INITIAL_GLOBAL_STATE.stickers,
diceSetIdByEmoji,
setsById: pickTruthy(setsById, Object.values(diceSetIdByEmoji || {})),
};
}
function reduceCustomEmojis<T extends GlobalState>(global: T): GlobalState['customEmojis'] {
const { lastRendered, byId } = global.customEmojis;
const folderEmojiIds = Object.values(global.chatFolders.byId)

View File

@ -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');
}

View File

@ -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;
}

View File

@ -726,7 +726,7 @@ export function selectAllowedMessageActionsSlow<T extends GlobalState>(
) && !(
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<T extends GlobalState>(
const canReport = !isPrivate && !isOwn;
const canDeleteForAll = canDelete && !chat.isForbidden && (
(isPrivate && !isChatWithSelf && !isBotChat)
(isPrivate && !isChatWithSelf && !isBotChat && !content.dice)
|| (isBasicGroup && (
isOwn || getHasAdminRight(chat, 'deleteMessages') || chat.isCreator
))

View File

@ -250,3 +250,28 @@ export function selectGiftStickerForTon<T extends GlobalState>(global: T, amount
export function selectCustomEmoji<T extends GlobalState>(global: T, documentId: string) {
return global.customEmojis.byId[documentId];
}
export function selectIdleDiceSticker<T extends GlobalState>(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<T extends GlobalState>(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);
}

View File

@ -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;

View File

@ -366,6 +366,7 @@ export type GlobalState = {
stickers: ApiSticker[];
emojis: ApiSticker[];
};
diceSetIdByEmoji?: Record<string, string>;
};
customEmojis: {

View File

@ -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<typeof RLottie>) {
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) {

View File

@ -160,4 +160,6 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
typingDraftTtl: 10,
arePasskeysAvailable: true,
passkeysMaxCount: 5,
diceEmojies: [],
diceEmojiesSuccess: {},
};

View File

@ -723,6 +723,7 @@ export type SendMessageParams = {
gif?: ApiVideo;
poll?: ApiNewPoll;
todo?: ApiNewMediaTodo;
dice?: string;
contact?: ApiContact;
isSilent?: boolean;
scheduledAt?: number;

View File

@ -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<V = LangVariable> {
'BotReadTextFromClipboardDescription': {
'bot': V;
};
'DiceToast': {
'emoji': V;
};
'GroupStatusWithOnline': {
'status': V;
'onlineCount': V;