diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 7a6a74d09..c76c77c4d 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -33,6 +33,7 @@ interface GramJsAppConfig extends LimitsConfig { premium_bot_username: string; premium_invoice_slug: string; premium_promo_order: string[]; + default_emoji_statuses_stickerset_id: string; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -64,8 +65,8 @@ export function buildAppConfig(json: GramJs.TypeJSONValue): ApiAppConfig { const appConfig = buildJson(json) as GramJsAppConfig; return { - emojiSounds: buildEmojiSounds(appConfig), defaultReaction: appConfig.reactions_default, + emojiSounds: buildEmojiSounds(appConfig), seenByMaxChatMembers: appConfig.chat_read_mark_size_threshold, seenByExpiresAt: appConfig.chat_read_mark_expire_period, autologinDomains: appConfig.autologin_domains || [], @@ -75,6 +76,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue): ApiAppConfig { premiumInvoiceSlug: appConfig.premium_invoice_slug, premiumPromoOrder: appConfig.premium_promo_order, isPremiumPurchaseBlocked: appConfig.premium_purchase_blocked, + defaultEmojiStatusesStickerSetId: appConfig.default_emoji_statuses_stickerset_id, limits: { uploadMaxFileparts: getLimit(appConfig, 'upload_max_fileparts', 'uploadMaxFileparts'), stickersFaved: getLimit(appConfig, 'stickers_faved_limit', 'stickersFaved'), diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index a5680421b..605461251 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -18,6 +18,7 @@ import { } from './peers'; import { omitVirtualClassFields } from './helpers'; import { getServerTime } from '../../../util/serverTime'; +import { buildApiReaction } from './messages'; type PeerEntityApiChatFields = Omit buildApiMessageEntity(l)), videoSections, - currency, videos: videos.map(buildApiDocument).filter(Boolean), - monthlyAmount: monthlyAmount.toString(), + options: periodOptions.map(buildApiPremiumSubscriptionOption), + }; +} + +function buildApiPremiumSubscriptionOption(option: GramJs.PremiumSubscriptionOption): ApiPremiumSubscriptionOption { + const { + current, canPurchaseUpgrade, currency, amount, botUrl, months, + } = option; + + return { + isCurrent: current, + canPurchaseUpgrade, + currency, + amount: amount.toString(), + botUrl, + months, }; } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 3aad3e410..fba83011b 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -1,5 +1,6 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { + ApiEmojiStatus, ApiPremiumGiftOption, ApiUser, ApiUserStatus, ApiUserType, } from '../../types'; @@ -66,6 +67,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { noStatus: !mtpUser.status, ...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }), ...(avatarHash && { avatarHash }), + ...(mtpUser.emojiStatus && { emojiStatus: buildApiUserEmojiStatus(mtpUser.emojiStatus) }), hasVideoAvatar, ...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }), ...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachBot: mtpUser.botAttachMenu }), @@ -99,6 +101,18 @@ export function buildApiUserStatus(mtpStatus?: GramJs.TypeUserStatus): ApiUserSt } } +export function buildApiUserEmojiStatus(mtpEmojiStatus: GramJs.TypeEmojiStatus): ApiEmojiStatus | undefined { + if (mtpEmojiStatus instanceof GramJs.EmojiStatus) { + return { documentId: mtpEmojiStatus.documentId.toString() }; + } + + if (mtpEmojiStatus instanceof GramJs.EmojiStatusUntil) { + return { documentId: mtpEmojiStatus.documentId.toString(), until: mtpEmojiStatus.until }; + } + + return undefined; +} + export function buildApiUsersAndStatuses(mtpUsers: GramJs.TypeUser[]) { const userStatusesById: Record = {}; const users: ApiUser[] = []; diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index ecfc1ffc8..3d23fa415 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -546,3 +546,16 @@ export function buildInputInvoice(invoice: ApiRequestInputInvoice) { }); } } + +export function buildInputReaction(reaction?: string) { + if (!reaction) return new GramJs.ReactionEmpty(); + return new GramJs.ReactionEmoji({ + emoticon: reaction, + }); +} + +export function buildInputChatReactions(chatReactions: string[]) { + return new GramJs.ChatReactionsSome({ + reactions: chatReactions.map(buildInputReaction), + }); +} diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 92fe0b351..4523b7db5 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -28,6 +28,7 @@ import { buildApiChatFolderFromSuggested, buildApiChatBotCommands, buildApiChatSettings, + buildApiChatReactions, } from '../apiBuilders/chats'; import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages'; import { buildApiUser, buildApiUsersAndStatuses } from '../apiBuilders/users'; @@ -40,6 +41,7 @@ import { isMessageWithMedia, buildChatBannedRights, buildChatAdminRights, + buildInputChatReactions, } from '../gramjsBuilders'; import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb } from '../helpers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; @@ -391,7 +393,7 @@ async function getFullChatInfo(chatId: string): Promise buildApiPeerId(userId, 'user')), }, @@ -508,7 +510,7 @@ async function getFullChannelInfo( groupCallId: call ? String(call.id) : undefined, linkedChatId: linkedChatId ? buildApiPeerId(linkedChatId, 'chat') : undefined, botCommands, - enabledReactions: availableReactions, + enabledReactions: buildApiChatReactions(availableReactions), sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined, requestsPending, recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')), @@ -1259,7 +1261,7 @@ export function setChatEnabledReactions({ }) { return invokeRequest(new GramJs.messages.SetChatAvailableReactions({ peer: buildInputPeer(chat.id, chat.accessHash), - availableReactions: enabledReactions, + availableReactions: buildInputChatReactions(enabledReactions), }), true); } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 34dbbd439..e10fb4008 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1317,7 +1317,7 @@ export async function fetchSendAs({ return { users, chats, - ids: result.peers.map(getApiChatIdFromMtpPeer), + ids: result.peers.map((sendAsPeer) => getApiChatIdFromMtpPeer(sendAsPeer.peer)), }; } diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index 3cbfaa407..00f66e981 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -1,7 +1,7 @@ -import type { ApiChat, ApiUser } from '../../types'; +import type { ApiChat } from '../../types'; import { invokeRequest } from './client'; import { Api as GramJs } from '../../../lib/gramjs'; -import { buildInputPeer } from '../gramjsBuilders'; +import { buildInputPeer, buildInputReaction } from '../gramjsBuilders'; import localDb from '../localDb'; import { buildApiAvailableReaction, buildMessagePeerReaction } from '../apiBuilders/messages'; import { REACTION_LIST_LIMIT } from '../../../config'; @@ -79,7 +79,7 @@ export function sendReaction({ chat: ApiChat; messageId: number; reaction?: string; }) { return invokeRequest(new GramJs.messages.SendReaction({ - ...(reaction && { reaction }), + ...(reaction && { reaction: [buildInputReaction(reaction)] }), peer: buildInputPeer(chat.id, chat.accessHash), msgId: messageId, }), true); @@ -104,7 +104,7 @@ export async function fetchMessageReactionsList({ const result = await invokeRequest(new GramJs.messages.GetMessageReactionsList({ peer: buildInputPeer(chat.id, chat.accessHash), id: messageId, - ...(reaction && { reaction }), + ...(reaction && { reaction: buildInputReaction(reaction) }), limit: REACTION_LIST_LIMIT, ...(offset && { offset }), })); @@ -118,9 +118,9 @@ export async function fetchMessageReactionsList({ const { nextOffset, reactions, count } = result; return { - users: result.users.map(buildApiUser).filter(Boolean as any), + users: result.users.map(buildApiUser).filter(Boolean), nextOffset, - reactions: reactions.map(buildMessagePeerReaction), + reactions: reactions.map(buildMessagePeerReaction).filter(Boolean), count, }; } @@ -131,6 +131,6 @@ export function setDefaultReaction({ reaction: string; }) { return invokeRequest(new GramJs.messages.SetDefaultReaction({ - reaction, + reaction: buildInputReaction(reaction), })); } diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 237f143bd..b2111fbd6 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -24,7 +24,7 @@ import { buildApiChatFolder, buildApiChatSettings, } from './apiBuilders/chats'; -import { buildApiUser, buildApiUserStatus } from './apiBuilders/users'; +import { buildApiUser, buildApiUserEmojiStatus, buildApiUserStatus } from './apiBuilders/users'; import { buildMessageFromUpdate, isMessageWithMedia, @@ -738,6 +738,14 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { userId: buildApiPeerId(update.userId, 'user'), status: buildApiUserStatus(update.status), }); + } else if (update instanceof GramJs.UpdateUserEmojiStatus) { + const emojiStatus = buildApiUserEmojiStatus(update.emojiStatus); + if (!emojiStatus) return; + onUpdate({ + '@type': 'updateUserEmojiStatus', + userId: buildApiPeerId(update.userId, 'user'), + emojiStatus, + }); } else if (update instanceof GramJs.UpdateUserName) { const apiUserId = buildApiPeerId(update.userId, 'user'); const updatedUser = localDb.users[apiUserId]; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 39f4b402e..8a3aabf6f 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -160,8 +160,8 @@ export interface ApiCountryCode extends ApiCountry { } export interface ApiAppConfig { - emojiSounds: Record; defaultReaction: string; + emojiSounds: Record; seenByMaxChatMembers: number; seenByExpiresAt: number; autologinDomains: string[]; @@ -171,6 +171,7 @@ export interface ApiAppConfig { premiumBotUsername: string; isPremiumPurchaseBlocked: boolean; premiumPromoOrder: string[]; + defaultEmojiStatusesStickerSetId: string; limits: Record; } diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index a160dde9a..083aadf0b 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -65,10 +65,18 @@ export interface ApiReceipt { } export interface ApiPremiumPromo { - currency: string; - monthlyAmount: string; videoSections: string[]; videos: ApiDocument[]; statusText: string; statusEntities: ApiMessageEntity[]; + options: ApiPremiumSubscriptionOption[]; +} + +export interface ApiPremiumSubscriptionOption { + isCurrent?: boolean; + canPurchaseUpgrade?: boolean; + months: number; + currency: string; + amount: string; + botUrl: string; } diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 0fde9bcf5..a70d4804d 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -15,7 +15,9 @@ import type { import type { ApiFormattedText, ApiMessage, ApiPhoto, ApiPoll, ApiReactions, ApiStickerSet, ApiThreadInfo, } from './messages'; -import type { ApiUser, ApiUserFullInfo, ApiUserStatus } from './users'; +import type { + ApiEmojiStatus, ApiUser, ApiUserFullInfo, ApiUserStatus, +} from './users'; import type { ApiEmojiInteraction, ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData, } from './misc'; @@ -324,6 +326,12 @@ export type ApiUpdateUserStatus = { status: ApiUserStatus; }; +export type ApiUpdateUserEmojiStatus = { + '@type': 'updateUserEmojiStatus'; + userId: string; + emojiStatus: ApiEmojiStatus; +}; + export type ApiUpdateUserFullInfo = { '@type': 'updateUserFullInfo'; id: string; @@ -548,7 +556,7 @@ export type ApiUpdate = ( ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted | ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState | - ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio + ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 2a7061d24..adcff0af3 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -28,6 +28,7 @@ export interface ApiUser { }; fakeType?: ApiFakeType; isAttachBot?: boolean; + emojiStatus?: ApiEmojiStatus; // Obtained from GetFullUser / UserFullInfo fullInfo?: ApiUserFullInfo; @@ -79,3 +80,8 @@ export interface ApiPremiumGiftOption { amount: number; botUrl: string; } + +export interface ApiEmojiStatus { + documentId: string; + until?: number; +} diff --git a/src/components/common/AnimatedSticker.tsx b/src/components/common/AnimatedSticker.tsx index 878392fa8..ce4b26173 100644 --- a/src/components/common/AnimatedSticker.tsx +++ b/src/components/common/AnimatedSticker.tsx @@ -8,8 +8,10 @@ import React, { import { fastRaf } from '../../util/schedulers'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; + import useHeavyAnimationCheck from '../../hooks/useHeavyAnimationCheck'; import useBackgroundMode from '../../hooks/useBackgroundMode'; +import useOnChange from '../../hooks/useOnChange'; export type OwnProps = { ref?: RefObject; @@ -28,6 +30,7 @@ export type OwnProps = { onClick?: NoneToVoidFunction; onLoad?: NoneToVoidFunction; onEnded?: NoneToVoidFunction; + onLoop?: NoneToVoidFunction; }; type RLottieClass = typeof import('../../lib/rlottie/RLottie').default; @@ -66,6 +69,7 @@ const AnimatedSticker: FC = ({ onClick, onLoad, onEnded, + onLoop, }) => { // eslint-disable-next-line no-null/no-null let containerRef = useRef(null); @@ -102,9 +106,10 @@ const AnimatedSticker: FC = ({ quality, isLowPriority, }, - onLoad, color, + onLoad, onEnded, + onLoop, ); if (speed) { @@ -125,7 +130,7 @@ const AnimatedSticker: FC = ({ }); }); } - }, [color, animation, tgsUrl, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded]); + }, [color, animation, tgsUrl, isLowPriority, noLoop, onLoad, quality, size, speed, onEnded, onLoop]); useEffect(() => { if (!animation) return; @@ -186,6 +191,12 @@ const AnimatedSticker: FC = ({ fastRaf(unfreezeAnimation); }, [unfreezeAnimation]); + useOnChange(([prevNoLoop]) => { + if (noLoop !== undefined && noLoop !== prevNoLoop) { + animation?.setNoLoop(noLoop); + } + }, [noLoop, animation]); + useEffect(() => { if (!animation) { return; diff --git a/src/components/common/CustomEmoji.module.scss b/src/components/common/CustomEmoji.module.scss new file mode 100644 index 000000000..4d6a14824 --- /dev/null +++ b/src/components/common/CustomEmoji.module.scss @@ -0,0 +1,36 @@ +.root { + display: inline-block; + vertical-align: text-bottom; + width: var(--custom-emoji-size); + height: var(--custom-emoji-size); + + &.with-grid-fix .media { + width: calc(100% + 1px) !important; + height: calc(100% + 1px) !important; + vertical-align: baseline; + } + + :global(.emoji-small) { + vertical-align: baseline !important; // Fix for fallback on Windows, when custom emoji not ready + } + + &:global(.custom-color) { + --emoji-status-color: var(--color-primary); + } +} + +.media { + width: 100%; + height: 100%; +} + +.sticker { + width: var(--custom-emoji-size) !important; + height: var(--custom-emoji-size) !important; + display: flex !important; + + :global(canvas) { + width: var(--custom-emoji-size) !important; + height: var(--custom-emoji-size) !important; + } +} diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index 2c8d93a8c..8d250473d 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -1,6 +1,7 @@ import React, { - memo, useEffect, useMemo, useRef, + memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; +import { getGlobal } from '../../global'; import type { FC, TeactNode } from '../../lib/teact/teact'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; @@ -8,6 +9,10 @@ import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { IS_WEBM_SUPPORTED } from '../../util/environment'; import renderText from './helpers/renderText'; import safePlay from '../../util/safePlay'; +import { getPropertyHexColor } from '../../util/themeStyle'; +import { hexToRgb } from '../../util/switchTheme'; +import buildClassName from '../../util/buildClassName'; +import { selectIsAlwaysHighPriorityEmoji, selectIsDefaultEmojiStatusPack } from '../../global/selectors'; import useMedia from '../../hooks/useMedia'; import useEnsureCustomEmoji from '../../hooks/useEnsureCustomEmoji'; @@ -17,26 +22,56 @@ import useCustomEmoji from './hooks/useCustomEmoji'; import AnimatedSticker from './AnimatedSticker'; +import styles from './CustomEmoji.module.scss'; + type OwnProps = { documentId: string; children?: TeactNode; + className?: string; + loopLimit?: number; + withGridFix?: boolean; observeIntersection?: ObserveFn; + onClick?: NoneToVoidFunction; }; const STICKER_SIZE = 24; -const CustomEmojiInner: FC = ({ +const CustomEmoji: FC = ({ documentId, children, + className, + loopLimit, + withGridFix, observeIntersection, + onClick, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); // An alternative to `withGlobal` to avoid adding numerous global containers const customEmoji = useCustomEmoji(documentId); - const mediaHash = customEmoji && `sticker${customEmoji.id}`; + const isUnsupportedVideo = customEmoji?.isVideo && !IS_WEBM_SUPPORTED; + const mediaHash = customEmoji && `sticker${customEmoji.id}${isUnsupportedVideo ? '?size=m' : ''}`; const mediaData = useMedia(mediaHash); const thumbDataUri = useThumbnail(customEmoji); + const loopCountRef = useRef(0); + const [shouldLoop, setShouldLoop] = useState(true); + const [customColor, setCustomColor] = useState<[number, number, number] | undefined>(); + + const hasCustomColor = customEmoji && selectIsDefaultEmojiStatusPack(getGlobal(), customEmoji.stickerSetInfo); + + useEffect(() => { + if (!hasCustomColor || !ref.current) { + setCustomColor(undefined); + return; + } + const hexColor = getPropertyHexColor(getComputedStyle(ref.current), '--emoji-status-color'); + if (!hexColor) { + setCustomColor(undefined); + return; + } + const customColorRgb = hexToRgb(hexColor); + setCustomColor([customColorRgb.r, customColorRgb.g, customColorRgb.b]); + }, [hasCustomColor]); const isIntersecting = useIsIntersecting(ref, observeIntersection); @@ -54,46 +89,99 @@ const CustomEmojiInner: FC = ({ } }, [customEmoji, isIntersecting]); + useEffect(() => { + if (!loopLimit) return undefined; + const video = ref.current?.querySelector('video'); + if (!mediaData || !video) return undefined; + + const returnToStart = () => { + loopCountRef.current += 1; + if (loopCountRef.current >= loopLimit) { + setShouldLoop(false); + video.currentTime = 0; + } else { + video.play(); + } + }; + + video.addEventListener('ended', returnToStart); + return () => video.removeEventListener('ended', returnToStart); + }, [loopLimit, mediaData]); + + const handleStickerLoop = useCallback(() => { + if (!loopLimit) return; + loopCountRef.current += 1; + // Sticker plays 1 more time after disabling loop + if (loopCountRef.current >= loopLimit - 1) { + setShouldLoop(false); + } + }, [loopLimit]); + const content = useMemo(() => { if (!customEmoji || (!thumbDataUri && !mediaData)) { return (children && renderText(children, ['emoji'])); } - if (!mediaData || (customEmoji.isVideo && !IS_WEBM_SUPPORTED)) { + + if (!mediaData) { return ( - {customEmoji.emoji} + {customEmoji.emoji} ); } - if (!customEmoji.isVideo && !customEmoji.isLottie) { + + if (isUnsupportedVideo || (!customEmoji.isVideo && !customEmoji.isLottie)) { return ( - {customEmoji.emoji} + {customEmoji.emoji} ); } + if (customEmoji.isVideo) { return (