Introduce Paid reactions (#4906)

This commit is contained in:
zubiden 2024-10-20 18:52:54 +02:00 committed by Alexander Zinchuk
parent dfe3666c00
commit eda7c3ee77
96 changed files with 2414 additions and 317 deletions

View File

@ -65,6 +65,7 @@ export interface GramJsAppConfig extends LimitsConfig {
giveaway_boosts_per_premium: number;
giveaway_countries_max: number;
boosts_per_sent_gift: number;
stars_paid_reaction_amount_max: number;
// Forums
topics_pinned_limit: number;
// Stories
@ -163,6 +164,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
bandwidthPremiumUploadSpeedup: appConfig.upload_premium_speedup_upload,
bandwidthPremiumDownloadSpeedup: appConfig.upload_premium_speedup_download,
channelRestrictAdsLevelMin: appConfig.channel_restrict_sponsored_level_min,
paidReactionMaxAmount: appConfig.stars_paid_reaction_amount_max,
isChannelRevenueWithdrawalEnabled: appConfig.channel_revenue_withdrawal_enabled,
isStarsGiftsEnabled: appConfig.stars_gifts_enabled,
};

View File

@ -530,7 +530,7 @@ export function buildApiChatReactions(chatReactions?: GramJs.TypeChatReactions):
if (chatReactions instanceof GramJs.ChatReactionsSome) {
return {
type: 'some',
allowed: chatReactions.reactions.map(buildApiReaction).filter(Boolean),
allowed: chatReactions.reactions.map((r) => buildApiReaction(r)).filter(Boolean),
};
}

View File

@ -503,7 +503,7 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction {
const {
date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift,
date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending, gift, reaction,
} = transaction;
if (photo) {
@ -527,6 +527,7 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
messageId: msgId,
isGift: gift,
extendedMedia: boughtExtendedMedia,
isReaction: reaction,
};
}

View File

@ -3,11 +3,12 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type {
ApiAvailableEffect,
ApiAvailableReaction,
ApiMessageReactor,
ApiPeerReaction,
ApiReaction,
ApiReactionCount,
ApiReactionEmoji,
ApiReactions,
ApiReactionWithPaid,
ApiSavedReactionTag,
} from '../../types';
@ -16,7 +17,7 @@ import { getApiChatIdFromMtpPeer } from './peers';
export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions {
const {
recentReactions, results, canSeeList, reactionsAsTags,
recentReactions, results, canSeeList, reactionsAsTags, topReactors,
} = reactions;
return {
@ -24,15 +25,21 @@ export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiRe
canSeeList,
results: results.map(buildReactionCount).filter(Boolean).sort(reactionCountComparator),
recentReactions: recentReactions?.map(buildMessagePeerReaction).filter(Boolean),
topReactors: topReactors?.map(buildApiMessageReactor).filter(Boolean),
};
}
function reactionCountComparator(a: ApiReactionCount, b: ApiReactionCount) {
if (a.reaction.type === 'paid') return -1;
if (b.reaction.type === 'paid') return 1;
const diff = b.count - a.count;
if (diff) return diff;
if (a.chosenOrder !== undefined && b.chosenOrder !== undefined) {
return a.chosenOrder - b.chosenOrder;
}
if (a.chosenOrder !== undefined) return 1;
if (b.chosenOrder !== undefined) return -1;
return 0;
@ -41,7 +48,7 @@ function reactionCountComparator(a: ApiReactionCount, b: ApiReactionCount) {
export function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCount | undefined {
const { chosenOrder, count, reaction } = reactionCount;
const apiReaction = buildApiReaction(reaction);
const apiReaction = buildApiReaction(reaction, true);
if (!apiReaction) return undefined;
return {
@ -51,6 +58,20 @@ export function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReac
};
}
export function buildApiMessageReactor(reactor: GramJs.MessageReactor): ApiMessageReactor {
const {
count, my, top, anonymous, peerId,
} = reactor;
return {
peerId: peerId && getApiChatIdFromMtpPeer(peerId),
count,
isMe: my,
isTop: top,
isAnonymous: anonymous,
};
}
export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiPeerReaction | undefined {
const {
peerId, reaction, big, unread, date, my,
@ -69,19 +90,29 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio
};
}
export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | undefined {
export function buildApiReaction(reaction: GramJs.TypeReaction, withPaid?: never): ApiReaction | undefined;
export function buildApiReaction(reaction: GramJs.TypeReaction, withPaid: true): ApiReactionWithPaid | undefined;
export function buildApiReaction(reaction: GramJs.TypeReaction, withPaid?: true): ApiReactionWithPaid | undefined {
if (reaction instanceof GramJs.ReactionEmoji) {
return {
type: 'emoji',
emoticon: reaction.emoticon,
};
}
if (reaction instanceof GramJs.ReactionCustomEmoji) {
return {
type: 'custom',
documentId: reaction.documentId.toString(),
};
}
if (withPaid && reaction instanceof GramJs.ReactionPaid) {
return {
type: 'paid',
};
}
return undefined;
}
@ -112,7 +143,7 @@ export function buildApiAvailableReaction(availableReaction: GramJs.AvailableRea
staticIcon: buildApiDocument(staticIcon),
aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined,
centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined,
reaction: { emoticon: reaction } as ApiReactionEmoji,
reaction: { type: 'emoji', emoticon: reaction },
title,
isInactive: inactive,
isPremium: premium,

View File

@ -58,6 +58,8 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT
content.text = buildMessageTextContent(caption, entities);
}
const reaction = sentReaction && buildApiReaction(sentReaction);
return omitUndefined<ApiStory>({
id,
peerId,
@ -75,7 +77,7 @@ export function buildApiStory(peerId: string, story: GramJs.TypeStoryItem): ApiT
isOut: out,
visibility: privacy && buildPrivacyRules(privacy),
mediaAreas: mediaAreas?.map(buildApiMediaArea).filter(Boolean),
sentReaction: sentReaction && buildApiReaction(sentReaction),
sentReaction: reaction,
forwardInfo: fwdFrom && buildApiStoryForwardInfo(fwdFrom),
fromId: fromId && getApiChatIdFromMtpPeer(fromId),
});
@ -197,7 +199,9 @@ export function buildApiMediaArea(area: GramJs.TypeMediaArea): ApiMediaArea | un
} = area;
const apiReaction = buildApiReaction(reaction);
if (!apiReaction) return undefined;
if (!apiReaction) {
return undefined;
}
return {
type: 'suggestedReaction',

View File

@ -18,7 +18,8 @@ import type {
ApiPhoneCall,
ApiPhoto,
ApiPoll,
ApiPremiumGiftCodeOption, ApiReaction,
ApiPremiumGiftCodeOption,
ApiReactionWithPaid,
ApiReportReason,
ApiRequestInputInvoice,
ApiSendMessageAction,
@ -291,6 +292,15 @@ export function generateRandomBigInt() {
return readBigIntFromBuffer(generateRandomBytes(8), true, true);
}
export function generateRandomTimestampedBigInt() {
// 32 bits for timestamp, 32 bits are random
const buffer = generateRandomBytes(8);
const timestampBuffer = Buffer.alloc(4);
timestampBuffer.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
buffer.set(timestampBuffer, 4);
return readBigIntFromBuffer(buffer, true, true);
}
export function generateRandomInt() {
return readBigIntFromBuffer(generateRandomBytes(4), true, true).toJSNumber();
}
@ -650,20 +660,21 @@ export function buildInputInvoice(invoice: ApiRequestInputInvoice) {
}
}
export function buildInputReaction(reaction?: ApiReaction) {
if (reaction && 'emoticon' in reaction) {
return new GramJs.ReactionEmoji({
emoticon: reaction.emoticon,
});
export function buildInputReaction(reaction?: ApiReactionWithPaid) {
switch (reaction?.type) {
case 'emoji':
return new GramJs.ReactionEmoji({
emoticon: reaction.emoticon,
});
case 'custom':
return new GramJs.ReactionCustomEmoji({
documentId: BigInt(reaction.documentId),
});
case 'paid':
return new GramJs.ReactionPaid();
default:
return new GramJs.ReactionEmpty();
}
if (reaction && 'documentId' in reaction) {
return new GramJs.ReactionCustomEmoji({
documentId: BigInt(reaction.documentId),
});
}
return new GramJs.ReactionEmpty();
}
export function buildInputChatReactions(chatReactions?: ApiChatReactions) {

View File

@ -602,6 +602,7 @@ async function getFullChannelInfo(
boostsApplied,
boostsUnrestrict,
canViewRevenue: canViewMonetization,
paidReactionsAvailable,
} = result.fullChat;
if (chatPhoto) {
@ -691,6 +692,7 @@ async function getFullChannelInfo(
hasPinnedStories: Boolean(storiesPinnedAvailable),
boostsApplied,
boostsToUnrestrict: boostsUnrestrict,
isPaidReactionAvailable: paidReactionsAvailable,
},
chats,
userStatusesById: statusesById,

View File

@ -1069,6 +1069,10 @@ export async function fetchFactChecks({
return results.flatMap((result) => result!).map(buildApiFactCheck);
}
export function fetchPaidReactionPrivacy() {
return invokeRequest(new GramJs.messages.GetPaidReactionPrivacy(), { shouldReturnTrue: true });
}
export async function fetchDiscussionMessage({
chat, messageId,
}: {

View File

@ -20,7 +20,7 @@ import {
buildMessagePeerReaction,
} from '../apiBuilders/reactions';
import { buildStickerFromDocument } from '../apiBuilders/symbols';
import { buildInputPeer, buildInputReaction } from '../gramjsBuilders';
import { buildInputPeer, buildInputReaction, generateRandomTimestampedBigInt } from '../gramjsBuilders';
import localDb from '../localDb';
import { invokeRequest } from './client';
@ -150,6 +150,29 @@ export function sendReaction({
});
}
export function sendPaidReaction({
chat,
messageId,
count,
isPrivate,
}: {
chat: ApiChat;
messageId: number;
count: number;
isPrivate?: boolean;
}) {
return invokeRequest(new GramJs.messages.SendPaidReaction({
peer: buildInputPeer(chat.id, chat.accessHash),
msgId: messageId,
randomId: generateRandomTimestampedBigInt(),
count,
private: isPrivate || undefined,
}), {
shouldReturnTrue: true,
shouldThrow: true,
});
}
export function fetchMessageReactions({
ids, chat,
}: {
@ -215,7 +238,7 @@ export async function fetchTopReactions({ hash = '0' }: { hash?: string }) {
return {
hash: String(result.hash),
reactions: result.reactions.map(buildApiReaction).filter(Boolean),
reactions: result.reactions.map((r) => buildApiReaction(r)).filter(Boolean),
};
}
@ -231,7 +254,7 @@ export async function fetchRecentReactions({ hash = '0' }: { hash?: string }) {
return {
hash: String(result.hash),
reactions: result.reactions.map(buildApiReaction).filter(Boolean),
reactions: result.reactions.map((r) => buildApiReaction(r)).filter(Boolean),
};
}
@ -250,7 +273,7 @@ export async function fetchDefaultTagReactions({ hash = '0' }: { hash?: string }
return {
hash: String(result.hash),
reactions: result.reactions.map(buildApiReaction).filter(Boolean),
reactions: result.reactions.map((r) => buildApiReaction(r)).filter(Boolean),
};
}

View File

@ -1009,11 +1009,12 @@ export function updater(update: Update) {
lastReadId: update.maxId,
});
} else if (update instanceof GramJs.UpdateSentStoryReaction) {
const reaction = buildApiReaction(update.reaction);
sendApiUpdate({
'@type': 'updateSentStoryReaction',
peerId: getApiChatIdFromMtpPeer(update.peer),
storyId: update.storyId,
reaction: buildApiReaction(update.reaction),
reaction,
});
} else if (update instanceof GramJs.UpdateStoriesStealthMode) {
sendApiUpdate({
@ -1044,6 +1045,11 @@ export function updater(update: Update) {
'@type': 'updateStarsBalance',
balance: update.balance.toJSNumber(),
});
} else if (update instanceof GramJs.UpdatePaidReactionPrivacy) {
sendApiUpdate({
'@type': 'updatePaidReactionPrivacy',
isPrivate: update.private,
});
} else if (update instanceof LocalUpdatePremiumFloodWait) {
sendApiUpdate({
'@type': 'updatePremiumFloodWait',

View File

@ -136,6 +136,7 @@ export interface ApiChatFullInfo {
areParticipantsHidden?: boolean;
isTranslationDisabled?: true;
hasPinnedStories?: boolean;
isPaidReactionAvailable?: boolean;
boostsApplied?: number;
boostsToUnrestrict?: number;

View File

@ -688,6 +688,7 @@ export interface ApiReactions {
areTags?: boolean;
results: ApiReactionCount[];
recentReactions?: ApiPeerReaction[];
topReactors?: ApiMessageReactor[];
}
export interface ApiPeerReaction {
@ -699,10 +700,20 @@ export interface ApiPeerReaction {
addedDate: number;
}
export interface ApiMessageReactor {
isTop?: true;
isMe?: true;
count: number;
isAnonymous?: true;
peerId?: string;
}
export interface ApiReactionCount {
chosenOrder?: number;
count: number;
reaction: ApiReaction;
reaction: ApiReactionWithPaid;
localAmount?: number;
localIsPrivate?: boolean;
}
export interface ApiAvailableReaction {
@ -741,16 +752,23 @@ type ApiChatReactionsSome = {
export type ApiChatReactions = ApiChatReactionsAll | ApiChatReactionsSome;
export type ApiReactionEmoji = {
type: 'emoji';
emoticon: string;
};
export type ApiReactionCustomEmoji = {
type: 'custom';
documentId: string;
};
export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji;
export type ApiReactionPaid = {
type: 'paid';
};
export type ApiReactionKey = `${string}-${string}`;
export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji;
export type ApiReactionWithPaid = ApiReaction | ApiReactionPaid;
export type ApiReactionKey = `${string}-${string}` | 'paid' | 'unsupported';
export type ApiSavedReactionTag = {
reaction: ApiReaction;

View File

@ -1,4 +1,5 @@
import type { ApiLimitType, ApiPremiumSection, CallbackAction } from '../../global/types';
import type { IconName } from '../../types/icons';
import type { ApiDocument, ApiPhoto, ApiReaction } from './messages';
import type { ApiUser } from './users';
@ -110,10 +111,15 @@ export type ApiNotification = {
localId: string;
title?: string;
message: string;
cacheBreaker?: string;
actionText?: string;
action?: CallbackAction | CallbackAction[];
className?: string;
duration?: number;
disableClickDismiss?: boolean;
shouldShowTimer?: boolean;
icon?: IconName;
dismissAction?: CallbackAction;
};
export type ApiError = {
@ -210,6 +216,7 @@ export interface ApiAppConfig {
bandwidthPremiumUploadSpeedup?: number;
bandwidthPremiumDownloadSpeedup?: number;
channelRestrictAdsLevelMin?: number;
paidReactionMaxAmount?: number;
isChannelRevenueWithdrawalEnabled?: boolean;
isStarsGiftsEnabled?: boolean;
}

View File

@ -303,6 +303,7 @@ export interface ApiStarsTransaction {
isGift?: true;
isPrizeStars?: true;
isMyGift?: true; // Used only for outgoing star gift messages
isReaction?: true;
hasFailed?: true;
isPending?: true;
date: number;

View File

@ -1,6 +1,12 @@
import type { ApiPrivacySettings } from '../../types';
import type {
ApiGeoPoint, ApiMessage, ApiReaction, ApiReactionCount, ApiSticker, ApiStoryForwardInfo, MediaContent,
ApiGeoPoint,
ApiMessage,
ApiReaction,
ApiReactionCount,
ApiSticker,
ApiStoryForwardInfo,
MediaContent,
} from './messages';
export interface ApiStory {

View File

@ -765,6 +765,11 @@ export type ApiUpdateEntities = {
threadInfos?: ApiThreadInfo[];
};
export type ApiUpdatePaidReactionPrivacy = {
'@type': 'updatePaidReactionPrivacy';
isPrivate: boolean;
};
export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -797,7 +802,7 @@ export type ApiUpdate = (
ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage |
ApiUpdateDeleteSavedHistory | ApiUpdatePremiumFloodWait | ApiUpdateStarsBalance |
ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies | ApiDeleteQuickReply | ApiUpdateDeleteQuickReplyMessages |
ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities
ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities | ApiUpdatePaidReactionPrivacy
);
export type OnApiUpdate = (update: ApiUpdate) => void;

Binary file not shown.

View File

@ -145,3 +145,9 @@
font-display:swap;
unicode-range: U+0600-06FF;
}
@font-face {
font-family: 'Roboto Round';
src: url('Roboto-Round-Regular.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@ -1283,6 +1283,10 @@
"CreditsBoxHistoryEntryGiftOutAbout" = "With Stars, {user} will be able to unlock content and services on Telegram. {link}"
"CreditsBoxOutAbout" = "Review the {link} for Stars."
"GiftStarsOutgoing" = "With Stars, {user} will be able to unlock content and services on Telegram."
"SendPaidReaction" = "Send ⭐️{amount}"
"StarsReactionTerms" = "By sending Stars you agree to the {link}"
"StarsReactionLinkText" = "Terms of Service"
"StarsReactionLink" = "https://telegram.org/tos/stars"
"MiniAppsMoreTabs_one" = "{botName} & {count} Other";
"MiniAppsMoreTabs_other" = "{botName} & {count} Others";
"PrizeCredits" = "Your prize is {count} Stars."

Binary file not shown.

Binary file not shown.

View File

@ -30,6 +30,7 @@ export { default as ChatlistModal } from '../components/modals/chatlist/Chatlist
export { default as StarsBalanceModal } from '../components/modals/stars/StarsBalanceModal';
export { default as StarPaymentModal } from '../components/modals/stars/StarsPaymentModal';
export { default as StarsTransactionInfoModal } from '../components/modals/stars/transaction/StarsTransactionModal';
export { default as PaidReactionModal } from '../components/modals/paidReaction/PaidReactionModal';
export { default as AboutAdsModal } from '../components/common/AboutAdsModal';
export { default as AboutMonetizationModal } from '../components/common/AboutMonetizationModal';

View File

@ -16,6 +16,7 @@ type OwnProps = {
text: string;
className?: string;
isDisabled?: boolean;
ref?: React.RefObject<HTMLSpanElement>;
};
const ANIMATION_TIME = 200;
@ -31,6 +32,7 @@ const AnimatedCounter: FC<OwnProps> = ({
text,
className,
isDisabled,
ref,
}) => {
const { isRtl } = useLang();
@ -58,7 +60,7 @@ const AnimatedCounter: FC<OwnProps> = ({
}, [shouldAnimate, text]);
return (
<span className={buildClassName(className, !isDisabled && styles.root)} dir={isRtl ? 'rtl' : undefined}>
<span ref={ref} className={buildClassName(className, !isDisabled && styles.root)} dir={isRtl ? 'rtl' : undefined}>
{characters}
</span>
);

View File

@ -50,11 +50,13 @@ import {
import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom';
import {
getAllowedAttachmentOptions,
getReactionKey,
getStoryKey,
hasReplaceableMedia,
isChatAdmin,
isChatChannel,
isChatSuperGroup,
isSameReaction,
isUserId,
} from '../../global/helpers';
import {
@ -436,8 +438,7 @@ const Composer: FC<OwnProps & StateProps> = ({
const { emojiSet, members: groupChatMembers, botCommands: chatBotCommands } = chatFullInfo || {};
const chatEmojiSetId = emojiSet?.id;
const isSentStoryReactionHeart = sentStoryReaction && 'emoticon' in sentStoryReaction
? sentStoryReaction.emoticon === HEART_REACTION.emoticon : false;
const isSentStoryReactionHeart = sentStoryReaction && isSameReaction(sentStoryReaction, HEART_REACTION);
useEffect(processMessageInputForCustomEmoji, [getHtml]);
@ -1503,9 +1504,11 @@ const Composer: FC<OwnProps & StateProps> = ({
let text: string | undefined;
let entities: ApiMessageEntity[] | undefined;
if ('emoticon' in reaction) {
if (reaction.type === 'emoji') {
text = reaction.emoticon;
} else {
}
if (reaction.type === 'custom') {
const sticker = getGlobal().customEmojis.byId[reaction.documentId];
if (!sticker) {
return;
@ -1983,7 +1986,7 @@ const Composer: FC<OwnProps & StateProps> = ({
>
{sentStoryReaction && (
<ReactionAnimatedEmoji
key={'documentId' in sentStoryReaction ? sentStoryReaction.documentId : sentStoryReaction.emoticon}
key={getReactionKey(sentStoryReaction)}
containerId={getStoryKey(chatId, storyId!)}
reaction={sentStoryReaction}
withEffectOnly={isSentStoryReactionHeart}

View File

@ -5,7 +5,7 @@ import React, {
import { getGlobal, withGlobal } from '../../global';
import type {
ApiAvailableReaction, ApiReaction, ApiSticker, ApiStickerSet,
ApiAvailableReaction, ApiReaction, ApiReactionWithPaid, ApiSticker, ApiStickerSet,
} from '../../api/types';
import type { StickerSetOrReactionsSetOrRecent } from '../../types';
@ -58,12 +58,13 @@ type OwnProps = {
loadAndPlay: boolean;
idPrefix?: string;
withDefaultTopicIcons?: boolean;
onCustomEmojiSelect: (sticker: ApiSticker) => void;
onReactionSelect?: (reaction: ApiReaction) => void;
selectedReactionIds?: string[];
isStatusPicker?: boolean;
isReactionPicker?: boolean;
isTranslucent?: boolean;
onCustomEmojiSelect: (sticker: ApiSticker) => void;
onReactionSelect?: (reaction: ApiReactionWithPaid) => void;
onReactionContext?: (reaction: ApiReactionWithPaid) => void;
onContextMenuOpen?: NoneToVoidFunction;
onContextMenuClose?: NoneToVoidFunction;
onContextMenuClick?: NoneToVoidFunction;
@ -86,6 +87,7 @@ type StateProps = {
canAnimate?: boolean;
isSavedMessages?: boolean;
isCurrentUserPremium?: boolean;
isWithPaidReaction?: boolean;
};
const HEADER_BUTTON_WIDTH = 2.5 * REM; // px (including margin)
@ -128,8 +130,10 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
defaultTopicIconsId,
defaultStatusIconsId,
defaultTagReactions,
isWithPaidReaction,
onCustomEmojiSelect,
onReactionSelect,
onReactionContext,
onContextMenuOpen,
onContextMenuClose,
onContextMenuClick,
@ -186,7 +190,10 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
}
if (isReactionPicker && !isSavedMessages) {
const topReactionsSlice = topReactions?.slice(0, TOP_REACTIONS_COUNT) || [];
const topReactionsSlice: ApiReactionWithPaid[] = topReactions?.slice(0, TOP_REACTIONS_COUNT) || [];
if (isWithPaidReaction) {
topReactionsSlice.unshift({ type: 'paid' });
}
if (topReactionsSlice?.length) {
defaultSets.push({
id: TOP_SYMBOL_SET_ID,
@ -271,6 +278,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
addedCustomEmojiIds, isReactionPicker, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis,
customEmojiFeaturedIds, stickerSetsById, topReactions, availableReactions, lang, recentReactions,
defaultStatusIconsId, defaultTopicIconsId, isSavedMessages, defaultTagReactions, chatEmojiSetId,
isWithPaidReaction,
]);
const noPopulatedSets = useMemo(() => (
@ -303,10 +311,6 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
onCustomEmojiSelect(emoji);
});
const handleReactionSelect = useLastCallback((reaction: ApiReaction) => {
onReactionSelect?.(reaction);
});
function renderCover(stickerSet: StickerSetOrReactionsSetOrRecent, index: number) {
const firstSticker = stickerSet.stickers?.[0];
const buttonClassName = buildClassName(
@ -441,7 +445,8 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
selectedReactionIds={selectedReactionIds}
availableReactions={availableReactions}
isTranslucent={isTranslucent}
onReactionSelect={handleReactionSelect}
onReactionSelect={onReactionSelect}
onReactionContext={onReactionContext}
onStickerSelect={handleEmojiSelect}
onContextMenuOpen={onContextMenuOpen}
onContextMenuClose={onContextMenuClose}
@ -495,6 +500,7 @@ export default memo(withGlobal<OwnProps>(
topReactions: isReactionPicker ? topReactions : undefined,
recentReactions: isReactionPicker ? recentReactions : undefined,
chatEmojiSetId: chatFullInfo?.emojiSet?.id,
isWithPaidReaction: isReactionPicker && chatFullInfo?.isPaidReactionAvailable,
availableReactions: isReactionPicker ? availableReactions : undefined,
defaultTagReactions: isReactionPicker ? defaultTags : undefined,
};

View File

@ -0,0 +1,44 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.top {
display: grid;
place-items: center;
position: relative;
}
.badge {
position: absolute;
bottom: -0.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 1;
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1;
white-space: nowrap;
padding: 0.25rem;
background-color: var(--color-primary);
color: var(--color-white);
border: 2px solid var(--color-background);
border-radius: 1rem;
}
.text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
font-size: 0.875rem;
width: 100%;
margin-bottom: 0;
}

View File

@ -0,0 +1,50 @@
import React, { memo } from '../../lib/teact/teact';
import type { ApiPeer } from '../../api/types';
import type { CustomPeer } from '../../types';
import type { IconName } from '../../types/icons';
import buildClassName from '../../util/buildClassName';
import Avatar from './Avatar';
import Icon from './icons/Icon';
import styles from './PeerBadge.module.scss';
type OwnProps = {
peer: ApiPeer | CustomPeer;
text?: string;
badgeText: string;
badgeIcon?: IconName;
className?: string;
badgeClassName?: string;
onClick?: NoneToVoidFunction;
};
const PeerBadge = ({
peer,
text,
badgeText,
badgeIcon,
className,
badgeClassName,
onClick,
}: OwnProps) => {
return (
<div
className={buildClassName(styles.root, onClick && styles.clickable, className)}
onClick={onClick}
>
<div className={styles.top}>
<Avatar size="large" peer={peer} />
<div className={buildClassName(styles.badge, badgeClassName)}>
{badgeIcon && <Icon name={badgeIcon} />}
{badgeText}
</div>
</div>
{text && <p className={styles.text}>{text}</p>}
</div>
);
};
export default memo(PeerBadge);

View File

@ -0,0 +1,49 @@
.root {
position: absolute;
width: 100%;
height: 100%;
z-index: -1;
line-height: 1;
pointer-events: none;
}
.progress {
--_progress: 0;
z-index: 0;
font-size: 0.75rem;
opacity: 0.8;
overflow: hidden;
}
.reaction {
font-size: 0.5rem;
}
.symbol {
--_duration-shift: 0s;
--_shift-x: 0;
--_shift-y: 0;
position: absolute;
width: 0.5rem;
height: 0.5rem;
animation: sparkle 5s infinite;
animation-delay: var(--_duration-shift);
}
@keyframes sparkle {
0% {
opacity: 0;
transform: translate(0, 0);
}
15% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--_shift-x), var(--_shift-y));
}
}

View File

@ -0,0 +1,156 @@
import React, { memo } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import styles from './Sparkles.module.scss';
type ReactionParameters = {
preset: 'reaction';
};
type ProgressParameters = {
preset: 'progress';
};
type PresetParameters = ReactionParameters | ProgressParameters;
type OwnProps = {
className?: string;
} & PresetParameters;
const SYMBOL = '✦';
const ANIMATION_DURATION = 5;
// Values are in percents
const REACTION_POSITIONS = [{
x: 20,
y: 0,
size: 100,
durationShift: 10,
}, {
x: 15,
y: 15,
size: 75,
durationShift: 70,
}, {
x: 10,
y: 35,
size: 75,
durationShift: 90,
}, {
x: 20,
y: 70,
size: 125,
durationShift: 30,
}, {
x: 40,
y: 10,
size: 125,
durationShift: 0,
}, {
x: 45,
y: 60,
size: 75,
durationShift: 60,
}, {
x: 60,
y: -10,
size: 100,
durationShift: 20,
}, {
x: 55,
y: 40,
size: 75,
durationShift: 60,
}, {
x: 70,
y: 65,
size: 100,
durationShift: 90,
}, {
x: 80,
y: 10,
size: 75,
durationShift: 30,
}, {
x: 80,
y: 45,
size: 125,
durationShift: 0,
}];
const PROGRESS_POSITIONS = generateRandomProgressPositions(100);
const Sparkles = ({
className,
...presetSettings
}: OwnProps) => {
if (presetSettings.preset === 'reaction') {
return (
<div className={buildClassName(styles.root, styles.reaction, className)}>
{REACTION_POSITIONS.map((position) => {
const shiftX = Math.cos(Math.atan2(-50 + position.y, -50 + position.x)) * 100;
const shiftY = Math.sin(Math.atan2(-50 + position.y, -50 + position.x)) * 100;
return (
<div
className={styles.symbol}
style={buildStyle(
`top: ${position.y}%`,
`left: ${position.x}%`,
`--_duration-shift: ${(-position.durationShift / 100) * ANIMATION_DURATION}s`,
`--_shift-x: ${shiftX}%`,
`--_shift-y: ${shiftY}%`,
`scale: ${position.size}%`,
)}
>
{SYMBOL}
</div>
);
})}
</div>
);
}
if (presetSettings.preset === 'progress') {
return (
<div className={buildClassName(styles.root, styles.progress, className)}>
{PROGRESS_POSITIONS.map((position) => {
return (
<div
className={styles.symbol}
style={buildStyle(
`top: ${position.y}%`,
`left: ${position.x}%`,
`--_shift-x: ${position.velocityX}%`,
`--_shift-y: ${position.velocityY}%`,
`scale: ${position.scale}%`,
`--_duration-shift: ${(-position.durationShift / 100) * ANIMATION_DURATION}s`,
)}
>
{SYMBOL}
</div>
);
})}
</div>
);
}
return undefined;
};
function generateRandomProgressPositions(count: number) {
const positions = [];
for (let i = 0; i < count; i++) {
positions.push({
x: Math.random() * 100,
y: Math.random() * 100,
velocityX: (Math.random() * 5 + 15) * 100,
velocityY: (Math.random() * 10 - 5) * 100,
scale: (Math.random() * 0.5 + 0.5) * 100,
durationShift: Math.random() * 100,
});
}
return positions;
}
export default memo(Sparkles);

View File

@ -4,7 +4,7 @@ import React, {
} from '../../lib/teact/teact';
import { getActions, getGlobal } from '../../global';
import type { ApiAvailableReaction, ApiReaction, ApiSticker } from '../../api/types';
import type { ApiAvailableReaction, ApiReactionWithPaid, ApiSticker } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { StickerSetOrReactionsSetOrRecent } from '../../types';
@ -35,7 +35,7 @@ import useWindowSize from '../../hooks/window/useWindowSize';
import Button from '../ui/Button';
import ConfirmDialog from '../ui/ConfirmDialog';
import Icon from './icons/Icon';
import ReactionEmoji from './ReactionEmoji';
import ReactionEmoji from './reactions/ReactionEmoji';
import StickerButton from './StickerButton';
import grey from '../../assets/icons/forumTopic/grey.svg';
@ -65,7 +65,8 @@ type OwnProps = {
observeIntersectionForShowingItems: ObserveFn;
availableReactions?: ApiAvailableReaction[];
onStickerSelect?: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onReactionSelect?: (reaction: ApiReaction) => void;
onReactionSelect?: (reaction: ApiReactionWithPaid) => void;
onReactionContext?: (reaction: ApiReactionWithPaid) => void;
onStickerUnfave?: (sticker: ApiSticker) => void;
onStickerFave?: (sticker: ApiSticker) => void;
onStickerRemoveRecent?: (sticker: ApiSticker) => void;
@ -105,6 +106,7 @@ const StickerSet: FC<OwnProps> = ({
observeIntersectionForPlayingItems,
observeIntersectionForShowingItems,
onReactionSelect,
onReactionContext,
onStickerSelect,
onStickerUnfave,
onStickerFave,
@ -351,6 +353,7 @@ const StickerSet: FC<OwnProps> = ({
availableReactions={availableReactions}
observeIntersection={observeIntersectionForPlayingItems}
onClick={onReactionSelect!}
onContextMenu={onReactionContext}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
forcePlayback={forcePlayback}

View File

@ -26,6 +26,8 @@ import FoldersAll from '../../../assets/tgs/settings/FoldersAll.tgs';
import FoldersNew from '../../../assets/tgs/settings/FoldersNew.tgs';
import FoldersShare from '../../../assets/tgs/settings/FoldersShare.tgs';
import Lock from '../../../assets/tgs/settings/Lock.tgs';
import StarReaction from '../../../assets/tgs/stars/StarReaction.tgs';
import StarReactionEffect from '../../../assets/tgs/stars/StarReactionEffect.tgs';
import Unlock from '../../../assets/tgs/Unlock.tgs';
export const LOCAL_TGS_URLS = {
@ -58,4 +60,6 @@ export const LOCAL_TGS_URLS = {
LastSeen,
Mention,
Fragment,
StarReactionEffect,
StarReaction,
};

View File

@ -11,18 +11,19 @@ type OwnProps = {
type?: 'gold' | 'premium' | 'regular';
size?: 'small' | 'middle' | 'big' | 'adaptive';
className?: string;
style?: string;
onClick?: VoidFunction;
};
/* eslint-disable max-len */
const STAR_PATH = 'M6.63869 12.1902L3.50621 14.1092C3.18049 14.3087 2.75468 14.2064 2.55515 13.8807C2.45769 13.7216 2.42864 13.5299 2.47457 13.3491L2.95948 11.4405C3.13452 10.7515 3.60599 10.1756 4.24682 9.86791L7.6642 8.22716C7.82352 8.15067 7.89067 7.95951 7.81418 7.80019C7.75223 7.67116 7.61214 7.59896 7.47111 7.62338L3.66713 8.28194C2.89387 8.41581 2.1009 8.20228 1.49941 7.69823L0.297703 6.69116C0.00493565 6.44581 -0.0335059 6.00958 0.211842 5.71682C0.33117 5.57442 0.502766 5.48602 0.687982 5.47153L4.35956 5.18419C4.61895 5.16389 4.845 4.99974 4.94458 4.75937L6.36101 1.3402C6.5072 0.987302 6.91179 0.819734 7.26469 0.965925C7.43413 1.03612 7.56876 1.17075 7.63896 1.3402L9.05539 4.75937C9.15496 4.99974 9.38101 5.16389 9.6404 5.18419L13.3322 5.47311C13.713 5.50291 13.9975 5.83578 13.9677 6.2166C13.9534 6.39979 13.8667 6.56975 13.7269 6.68896L10.9114 9.08928C10.7131 9.25826 10.6267 9.52425 10.6876 9.77748L11.5532 13.3733C11.6426 13.7447 11.414 14.1182 11.0427 14.2076C10.8642 14.2506 10.676 14.2208 10.5195 14.1249L7.36128 12.1902C7.13956 12.0544 6.8604 12.0544 6.63869 12.1902Z';
const GOLD_STAR_PATH = 'M10.5197 16.2049L6.46899 18.6864C6.04779 18.9444 5.49716 18.8121 5.23913 18.3909C5.11311 18.1852 5.07554 17.9373 5.13494 17.7035L5.762 15.2354C5.98835 14.3444 6.59803 13.5997 7.42671 13.2018L11.8459 11.0801C12.0519 10.9812 12.1387 10.734 12.0398 10.528C11.9597 10.3611 11.7786 10.2677 11.5962 10.2993L6.67709 11.1509C5.67715 11.324 4.65172 11.0479 3.87392 10.3961L2.31994 9.09382C1.94135 8.77655 1.89164 8.21245 2.20891 7.83386C2.36321 7.64972 2.58511 7.53541 2.82462 7.51667L7.5725 7.1451C7.90793 7.11885 8.20025 6.90658 8.32901 6.59574L10.1607 2.17427C10.3497 1.71792 10.8729 1.50123 11.3292 1.69028C11.5484 1.78105 11.7225 1.95514 11.8132 2.17427L13.6449 6.59574C13.7736 6.90658 14.066 7.11885 14.4014 7.1451L19.1754 7.51871C19.6678 7.55725 20.0358 7.9877 19.9972 8.48015C19.9787 8.71704 19.8666 8.93682 19.6858 9.09098L16.0449 12.1949C15.7886 12.4134 15.6768 12.7574 15.7556 13.0849L16.8749 17.7348C16.9905 18.215 16.6949 18.698 16.2147 18.8137C15.9839 18.8692 15.7406 18.8307 15.5382 18.7068L11.4541 16.2049C11.1674 16.0292 10.8064 16.0292 10.5197 16.2049Z';
/* eslint-enable max-len */
const StarIcon: FC<OwnProps> = ({
type = 'regular',
size = 'small',
className,
style,
onClick,
}) => {
const randomId = useUniqueId();
@ -38,6 +39,7 @@ const StarIcon: FC<OwnProps> = ({
onClick && styles.clickable,
styles[size],
)}
style={style}
>
{type === 'gold'
? <GoldStarIcon randomId={validSvgRandomId} />
@ -49,66 +51,75 @@ const StarIcon: FC<OwnProps> = ({
};
function GoldStarIcon({ randomId }: { randomId: string }) {
const fillId = `${randomId}-fill`;
const stroke1Id = `${randomId}-stroke1`;
const stroke2Id = `${randomId}-stroke2`;
const mask1Id = `${randomId}-mask1`;
const mask2Id = `${randomId}-mask2`;
const gradient1Id = `${randomId}-gradient1`;
const gradient2Id = `${randomId}-gradient2`;
const gradient3Id = `${randomId}-gradient3`;
return (
<svg className={styles.svg} width="21" height="20" viewBox="0 0 21 20" fill="none">
<svg className={styles.svg} width="20" height="20" viewBox="0 0 20 20" fill="none">
<g clip-path="url(#clip0_4913_7387)">
<mask id={mask1Id} style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-2" y="-2" width="24" height="24">
<path d="M21.416 -1.42493H-1.08398V21.0751H21.416V-1.42493Z" fill="white" />
</mask>
<g mask={`url(#${mask1Id})`}>
<mask
id={mask2Id}
style="mask-type:luminance"
maskUnits="userSpaceOnUse"
x="-2"
y="-2"
width="24"
height="24"
>
<path d="M-1.08398 -1.42493H21.416V21.0751H-1.08398V-1.42493Z" fill="white" />
</mask>
<g mask={`url(#${mask2Id})`}>
<path d="M7.26843 6.25162L9.28943 2.22541C9.52311 1.76121 10.0884 1.5749 10.5494 1.80857C10.7294 1.90015 10.8747 2.04857 10.9662 2.23172L12.8767 6.11583C13.0314 6.43477 13.3378 6.64951 13.6883 6.69056L17.6829 7.17055C18.2261 7.23686 18.6145 7.73264 18.5513 8.27894C18.5229 8.50314 18.4218 8.71156 18.2608 8.86945L15.0998 11.9862C14.9703 12.1125 14.9103 12.2894 14.9324 12.4694L15.4598 16.6756C15.5356 17.2787 15.1093 17.8282 14.5093 17.904C14.2819 17.9324 14.0546 17.8913 13.8525 17.7808L10.5147 15.9556C10.2715 15.823 9.98099 15.8198 9.73784 15.9461L6.27687 17.7208C5.79057 17.9703 5.1969 17.7745 4.94743 17.285C4.8527 17.1019 4.82112 16.8966 4.84954 16.6945L5.12427 14.7619C5.26006 13.8177 5.84425 12.9967 6.69055 12.5641L10.5305 10.6031C10.6315 10.5526 10.6726 10.4263 10.622 10.322C10.581 10.2431 10.4957 10.1957 10.4073 10.2084L5.70847 10.8841C4.99164 10.9852 4.26535 10.7831 3.7001 10.322L2.13698 9.04629C1.69173 8.68314 1.6191 8.02 1.98225 7.57159C2.15277 7.36317 2.39592 7.22739 2.66118 7.19265L6.6716 6.67793C6.92739 6.64319 7.15159 6.4853 7.26843 6.25162Z" fill={`url(#${gradient1Id})`} />
<path d="M10.8242 2.9422C10.4168 2.85062 9.98417 3.0464 9.78839 3.43797L7.76423 7.46419C7.64739 7.69787 7.42634 7.85576 7.1674 7.89049L3.15698 8.40837C2.89804 8.44311 2.6612 8.56942 2.49384 8.76837L3.97801 9.98097C4.44537 10.3631 5.05167 10.5304 5.64849 10.4452L10.3442 9.77255C10.6221 9.73466 10.8936 9.8736 11.0168 10.1231C11.1778 10.442 11.0515 10.8336 10.7326 10.9978L9.50734 11.623L10.9031 11.4209C10.9915 11.4083 11.0799 11.4557 11.1178 11.5346C11.1715 11.6388 11.1273 11.7651 11.0263 11.8157L7.18635 13.7767C6.34006 14.2093 5.75586 15.0303 5.62007 15.9745L5.44008 17.2282C5.60428 17.3924 5.86322 17.4366 6.07796 17.326L9.53261 15.5514C9.90839 15.3587 10.3536 15.365 10.7231 15.5671L14.0609 17.3924C14.1809 17.4587 14.3199 17.4839 14.4556 17.4682C14.8093 17.4239 15.0651 17.0955 15.0209 16.7324L14.4967 12.523C14.4588 12.2072 14.5662 11.8946 14.7904 11.6736L17.9513 8.55679C18.005 8.50311 18.0461 8.43995 18.0745 8.37048L14.1841 7.90312C13.8336 7.86207 13.5304 7.64418 13.3725 7.3284L13.2209 7.01578C12.902 6.87683 12.6399 6.63052 12.482 6.31159L10.8242 2.9422Z" fill={`url(#${gradient2Id})`} />
<path d="M10.7484 1.41397C10.0663 1.06977 9.23893 1.3445 8.89789 2.02659L6.87373 6.05596C6.8232 6.15701 6.72531 6.22649 6.61478 6.23912L2.60436 6.757C2.22542 6.80437 1.87806 7.00015 1.63807 7.29699C1.12334 7.93486 1.22124 8.86958 1.85596 9.38746L3.42223 10.6664C4.08222 11.2032 4.93167 11.44 5.77165 11.32L9.09051 10.8432L6.48847 12.1727C5.51271 12.6716 4.84325 13.6126 4.68852 14.6989L4.41063 16.6315C4.36958 16.9252 4.41695 17.222 4.55273 17.4873C4.90956 18.1915 5.77481 18.4725 6.479 18.1125L9.93681 16.3378C10.0505 16.2778 10.1863 16.281 10.3 16.3441L13.6378 18.1662C13.9188 18.3209 14.2441 18.3841 14.563 18.343C15.4061 18.2357 15.9998 17.462 15.8956 16.622L15.3714 12.4126C15.3651 12.3716 15.3809 12.3274 15.4093 12.299L18.5703 9.1822C18.8008 8.95168 18.9492 8.65169 18.9871 8.32959C19.0787 7.5433 18.5229 6.82963 17.7366 6.73489L13.7388 6.25491C13.5399 6.2328 13.363 6.10649 13.2715 5.92334L11.3641 2.03606C11.2315 1.76765 11.0136 1.54976 10.7484 1.41397ZM9.68418 2.42132C9.80734 2.17501 10.1073 2.07712 10.3505 2.20027C10.4452 2.24764 10.5242 2.32974 10.5715 2.42763L12.482 6.31175C12.7031 6.76332 13.1357 7.06962 13.6346 7.12962L17.6324 7.60961C17.9324 7.64435 18.1471 7.91908 18.1124 8.22538C18.0966 8.3517 18.0398 8.46854 17.9513 8.55695L14.7904 11.6737C14.5662 11.8948 14.4588 12.2074 14.4967 12.5232L15.0209 16.7325C15.0651 17.0957 14.8093 17.4241 14.4556 17.4683C14.3199 17.4841 14.1809 17.4588 14.0609 17.3925L10.7231 15.5673C10.3536 15.3652 9.90839 15.362 9.53261 15.5547L6.0748 17.3294C5.80638 17.4652 5.47797 17.3578 5.33903 17.0862C5.2885 16.9852 5.26956 16.8683 5.28534 16.7546L5.56007 14.8252C5.67376 14.0231 6.17269 13.3253 6.88952 12.959L10.7294 10.9979C11.0515 10.8337 11.1778 10.4422 11.0168 10.1232C10.8905 9.87376 10.6221 9.73482 10.3442 9.77271L5.64534 10.4453C5.04851 10.5306 4.44537 10.3632 3.97801 9.98113L2.41489 8.70221C2.15595 8.49064 2.1149 8.10854 2.32647 7.8496C2.42436 7.72961 2.56331 7.65066 2.71488 7.63171L6.72531 7.11383C7.12951 7.06331 7.48003 6.81384 7.66318 6.45069L9.68418 2.42132Z" fill={`url(#${gradient3Id})`} />
</g>
</g>
</g>
<defs>
<linearGradient
id={fillId}
x1="0.434893"
y1="22.5796"
x2="34.2364"
y2="-15.5089"
id={gradient1Id}
x1="10.1547"
y1="1.70752"
x2="10.1547"
y2="17.9134"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FDEB32" />
<stop offset="0.439058" stop-color="#FEBD04" />
<stop offset="1" stop-color="#D75902" />
<stop stop-color="#FFF0C2" />
<stop offset="1" stop-color="#FFEBBA" />
</linearGradient>
<linearGradient
id={stroke1Id}
x1="22.5"
y1="2.5"
x2="8"
y2="12.5"
id={gradient2Id}
x1="10.2842"
y1="2.92009"
x2="14.7304"
y2="17.4713"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#DB5A00" />
<stop offset="1" stop-color="#FF9145" />
<stop stop-color="#FFD147" />
<stop offset="1" stop-color="#FFB526" />
</linearGradient>
<linearGradient
id={stroke2Id}
x1="24.5"
y1="2"
x2="11"
y2="10.2302"
id={gradient3Id}
x1="10.1547"
y1="1.26556"
x2="14.0546"
y2="18.3525"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" stop-opacity="0" />
<stop offset="0.395833" stop-color="white" stop-opacity="0.85" />
<stop offset="0.520833" stop-color="white" />
<stop offset="0.645833" stop-color="white" stop-opacity="0.85" />
<stop offset="1" stop-color="white" stop-opacity="0" />
<stop stop-color="#E58F0D" />
<stop offset="1" stop-color="#EB7814" />
</linearGradient>
<clipPath id="clip0_4913_7387">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d={GOLD_STAR_PATH}
fill={`url(#${fillId})`}
stroke={`url(#${stroke1Id})`}
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d={GOLD_STAR_PATH}
stroke={`url(#${stroke2Id})`}
stroke-width="2"
style="mix-blend-mode:soft-light"
/>
</svg>
);
}

View File

@ -0,0 +1,148 @@
import React, {
memo, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiReaction, ApiReactionPaid } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { isSameReaction } from '../../../global/helpers';
import { selectPerformanceSettingsValue, selectTabState } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { IS_ANDROID, IS_IOS } from '../../../util/windowEnvironment';
import { LOCAL_TGS_URLS } from '../helpers/animatedAssets';
import { REM } from '../helpers/mediaDimensions';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback';
import useShowTransition from '../../../hooks/useShowTransition';
import AnimatedIcon from '../AnimatedIcon';
import StarIcon from '../icons/StarIcon';
import styles from './ReactionAnimatedEmoji.module.scss';
type OwnProps = {
containerId: string;
reaction: ApiReactionPaid;
className?: string;
size?: number;
effectSize?: number;
localAmount?: number;
observeIntersection?: ObserveFn;
};
type StateProps = {
activeReactions?: ApiReaction[];
withEffects?: boolean;
};
const ICON_SIZE = 1.5 * REM;
const EFFECT_SIZE = 6.5 * REM;
const MAX_EFFECT_COUNT = (IS_IOS || IS_ANDROID) ? 2 : 5;
const QUALITY = (IS_IOS || IS_ANDROID) ? 2 : 3;
const PaidReactionEmoji = ({
containerId,
reaction,
className,
size = ICON_SIZE,
effectSize = EFFECT_SIZE,
activeReactions,
localAmount,
withEffects,
observeIntersection,
}: OwnProps & StateProps) => {
const { stopActiveReaction } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const effectRef = useRef<HTMLDivElement>(null);
const [effectsIds, setEffectsIds] = useState<number[]>([]);
const isIntersecting = useIsIntersecting(ref, observeIntersection);
const activeReaction = useMemo(() => (
activeReactions?.find((active) => isSameReaction(active, reaction))
), [activeReactions, reaction]);
const shouldPlayEffect = Boolean(
withEffects && activeReaction,
);
const canAddMoreEffects = effectsIds.length < MAX_EFFECT_COUNT;
useEffectWithPrevDeps(([prevLocalAmount]) => {
if (!shouldPlayEffect) {
setEffectsIds([]);
return;
}
if (!localAmount || localAmount <= (prevLocalAmount || 0)) {
return;
}
if (canAddMoreEffects) {
setEffectsIds((prev) => [...prev, Date.now()]);
}
}, [localAmount, canAddMoreEffects, shouldPlayEffect]);
const {
shouldRender: shouldRenderEffect,
} = useShowTransition({
ref: effectRef,
noMountTransition: true,
isOpen: shouldPlayEffect,
className: 'slow',
withShouldRender: true,
});
const handleEnded = useLastCallback(() => {
const newEffectsIds = effectsIds.slice(1);
setEffectsIds(newEffectsIds);
if (!newEffectsIds.length) {
stopActiveReaction({ containerId, reaction });
}
});
const rootClassName = buildClassName(
styles.root,
shouldRenderEffect && styles.animating,
className,
);
return (
<div className={rootClassName} ref={ref} teactFastList>
<StarIcon key="icon" type="gold" size="adaptive" style={`width: ${size}px; height: ${size}px`} />
{shouldRenderEffect && effectsIds.map((id) => (
<AnimatedIcon
key={id}
ref={effectRef}
className={styles.effect}
size={effectSize}
tgsUrl={LOCAL_TGS_URLS.StarReactionEffect}
play={isIntersecting}
noLoop
nonInteractive
quality={QUALITY}
onEnded={handleEnded}
/>
))}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { containerId }) => {
const { activeReactions } = selectTabState(global);
const withEffects = selectPerformanceSettingsValue(global, 'reactionEffects');
return {
activeReactions: activeReactions?.[containerId],
withEffects,
};
},
)(PaidReactionEmoji));

View File

@ -3,7 +3,11 @@ import React, {
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiAvailableReaction, ApiReaction, ApiStickerSet } from '../../../api/types';
import type {
ApiAvailableReaction,
ApiReaction,
ApiStickerSet,
} from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { isSameReaction } from '../../../global/helpers';
@ -21,8 +25,8 @@ import useCustomEmoji from '../hooks/useCustomEmoji';
import AnimatedSticker from '../AnimatedSticker';
import CustomEmoji from '../CustomEmoji';
import ReactionStaticEmoji from '../ReactionStaticEmoji';
import CustomEmojiEffect from './CustomEmojiEffect';
import ReactionStaticEmoji from './ReactionStaticEmoji';
import styles from './ReactionAnimatedEmoji.module.scss';
@ -73,7 +77,7 @@ const ReactionAnimatedEmoji = ({
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isCustom = 'documentId' in reaction;
const isCustom = reaction.type === 'custom';
const availableReaction = useMemo(() => (
availableReactions?.find((r) => isSameReaction(r.reaction, reaction))

View File

@ -1,27 +1,29 @@
import type { FC } from '../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useMemo, useRef,
} from '../../lib/teact/teact';
memo, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import type { ApiAvailableReaction, ApiReaction } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { ApiAvailableReaction, ApiReactionWithPaid } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { EMOJI_SIZE_PICKER } from '../../config';
import { getDocumentMediaHash, isSameReaction } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { EMOJI_SIZE_PICKER } from '../../../config';
import { getDocumentMediaHash, isSameReaction } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { LOCAL_TGS_URLS } from '../helpers/animatedAssets';
import useCoordsInSharedCanvas from '../../hooks/useCoordsInSharedCanvas';
import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia';
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import useCoordsInSharedCanvas from '../../../hooks/useCoordsInSharedCanvas';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useMediaTransitionDeprecated from '../../../hooks/useMediaTransitionDeprecated';
import AnimatedIconWithPreview from './AnimatedIconWithPreview';
import CustomEmoji from './CustomEmoji';
import AnimatedIconWithPreview from '../AnimatedIconWithPreview';
import CustomEmoji from '../CustomEmoji';
import styles from './ReactionEmoji.module.scss';
type OwnProps = {
reaction: ApiReaction;
reaction: ApiReactionWithPaid;
availableReactions?: ApiAvailableReaction[];
className?: string;
isSelected?: boolean;
@ -30,7 +32,8 @@ type OwnProps = {
sharedCanvasRef?: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef?: React.RefObject<HTMLCanvasElement>;
forcePlayback?: boolean;
onClick: (reaction: ApiReaction) => void;
onClick: (reaction: ApiReactionWithPaid) => void;
onContextMenu?: (reaction: ApiReactionWithPaid) => void;
};
const ReactionEmoji: FC<OwnProps> = ({
@ -43,10 +46,11 @@ const ReactionEmoji: FC<OwnProps> = ({
sharedCanvasHqRef,
forcePlayback,
onClick,
onContextMenu,
}) => {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const isCustom = 'documentId' in reaction;
const isCustom = reaction.type === 'custom';
const availableReaction = useMemo(() => (
availableReactions?.find((available) => isSameReaction(available.reaction, reaction))
), [availableReactions, reaction]);
@ -57,6 +61,25 @@ const ReactionEmoji: FC<OwnProps> = ({
availableReaction?.selectAnimation ? getDocumentMediaHash(availableReaction.selectAnimation, 'full') : undefined,
!animationId,
);
const {
isContextMenuOpen,
handleBeforeContextMenu,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, reaction.type !== 'paid', undefined, undefined, undefined, true);
useEffect(() => {
if (isContextMenuOpen) {
onContextMenu?.(reaction);
handleContextMenuClose();
handleContextMenuHide();
}
}, [handleContextMenuClose, onContextMenu, handleContextMenuHide, isContextMenuOpen, reaction]);
const tgsUrl = reaction.type === 'paid' ? LOCAL_TGS_URLS.StarReaction : mediaData;
const handleClick = useLastCallback(() => {
onClick(reaction);
});
@ -75,6 +98,8 @@ const ReactionEmoji: FC<OwnProps> = ({
onClick={handleClick}
title={availableReaction?.title}
data-sticker-id={isCustom ? reaction.documentId : undefined}
onMouseDown={handleBeforeContextMenu}
onContextMenu={handleContextMenu}
>
{isCustom ? (
<CustomEmoji
@ -90,7 +115,7 @@ const ReactionEmoji: FC<OwnProps> = ({
/>
) : (
<AnimatedIconWithPreview
tgsUrl={mediaData}
tgsUrl={tgsUrl}
thumbDataUri={thumbDataUri}
play={loadAndPlay}
noLoop={false}

View File

@ -1,21 +1,20 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useMemo } from '../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { ApiAvailableReaction, ApiReaction } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../api/types';
import type { ApiAvailableReaction, ApiReaction } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { isSameReaction } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { isSameReaction } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import useMedia from '../../hooks/useMedia';
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
import useMedia from '../../../hooks/useMedia';
import useMediaTransitionDeprecated from '../../../hooks/useMediaTransitionDeprecated';
import CustomEmoji from './CustomEmoji';
import CustomEmoji from '../CustomEmoji';
import './ReactionStaticEmoji.scss';
import blankUrl from '../../assets/blank.png';
import blankUrl from '../../../assets/blank.png';
type OwnProps = {
reaction: ApiReaction;
@ -34,19 +33,19 @@ const ReactionStaticEmoji: FC<OwnProps> = ({
withIconHeart,
observeIntersection,
}) => {
const isCustom = 'documentId' in reaction;
const availableReaction = useMemo(() => (
availableReactions?.find((available) => isSameReaction(available.reaction, reaction))
), [availableReactions, reaction]);
const staticIconId = availableReaction?.staticIcon?.id;
const mediaData = useMedia(`document${staticIconId}`, !staticIconId, ApiMediaFormat.BlobUrl);
const mediaHash = staticIconId ? `document${staticIconId}` : undefined;
const mediaData = useMedia(mediaHash);
const transitionClassNames = useMediaTransitionDeprecated(mediaData);
const shouldApplySizeFix = 'emoticon' in reaction && reaction.emoticon === '🦄';
const shouldReplaceWithHeartIcon = withIconHeart && 'emoticon' in reaction && reaction.emoticon === '❤';
const shouldApplySizeFix = reaction.type === 'emoji' && reaction.emoticon === '🦄';
const shouldReplaceWithHeartIcon = withIconHeart && reaction.type === 'emoji' && reaction.emoticon === '❤';
if (isCustom) {
if (reaction.type === 'custom') {
return (
<CustomEmoji
documentId={reaction.documentId}

View File

@ -6,7 +6,7 @@ import type { ApiAvailableReaction } from '../../../api/types';
import useHistoryBack from '../../../hooks/useHistoryBack';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji';
import RadioGroup from '../../ui/RadioGroup';
type OwnProps = {
@ -47,7 +47,7 @@ const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
const handleChange = useCallback((reaction: string) => {
setDefaultReaction({
reaction: { emoticon: reaction },
reaction: { type: 'emoji', emoticon: reaction },
});
}, [setDefaultReaction]);

View File

@ -22,7 +22,7 @@ import useHistoryBack from '../../../hooks/useHistoryBack';
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
import useOldLang from '../../../hooks/useOldLang';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji';
import StickerSetCard from '../../common/StickerSetCard';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';

View File

@ -247,6 +247,7 @@ const Main = ({
loadStarStatus,
loadAvailableEffects,
loadTopBotApps,
loadPaidReactionPrivacy,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -330,6 +331,7 @@ const Main = ({
loadSavedReactionTags();
loadAuthorizations();
loadTopBotApps();
loadPaidReactionPrivacy();
}
}, [isMasterTab, isSynced]);

View File

@ -23,18 +23,23 @@ const Notifications: FC<StateProps> = ({ notifications }) => {
return (
<div id="Notifications">
{notifications.map(({
message, className, localId, action, actionText, title, duration,
}) => (
{notifications.map((notification) => (
<Notification
title={title ? renderText(title, ['simple_markdown', 'emoji', 'br', 'links']) : undefined}
action={action}
actionText={actionText}
className={className}
duration={duration}
message={renderText(message, ['simple_markdown', 'emoji', 'br', 'links'])}
key={notification.localId}
title={notification.title
? renderText(notification.title, ['simple_markdown', 'emoji', 'br', 'links']) : undefined}
action={notification.action}
actionText={notification.actionText}
className={notification.className}
duration={notification.duration}
icon={notification.icon}
cacheBreaker={notification.cacheBreaker}
message={renderText(notification.message, ['simple_markdown', 'emoji', 'br', 'links'])}
shouldDisableClickDismiss={notification.disableClickDismiss}
dismissAction={notification.dismissAction}
shouldShowTimer={notification.shouldShowTimer}
// eslint-disable-next-line react/jsx-no-bind
onDismiss={() => dismissNotification({ localId })}
onDismiss={() => dismissNotification({ localId: notification.localId })}
/>
))}
</div>

View File

@ -26,7 +26,7 @@ import useOldLang from '../../hooks/useOldLang';
import Avatar from '../common/Avatar';
import FullNameTitle from '../common/FullNameTitle';
import PrivateChatInfo from '../common/PrivateChatInfo';
import ReactionStaticEmoji from '../common/ReactionStaticEmoji';
import ReactionStaticEmoji from '../common/reactions/ReactionStaticEmoji';
import Button from '../ui/Button';
import InfiniteScroll from '../ui/InfiniteScroll';
import ListItem from '../ui/ListItem';

View File

@ -134,6 +134,7 @@ type StateProps = {
isInSavedMessages?: boolean;
isChannel?: boolean;
canReplyInChat?: boolean;
isWithPaidReaction?: boolean;
};
const selection = window.getSelection();
@ -192,9 +193,10 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canSelectLanguage,
isReactionPickerOpen,
isInSavedMessages,
canReplyInChat,
isWithPaidReaction,
onClose,
onCloseAnimationEnd,
canReplyInChat,
}) => {
const {
openThread,
@ -229,6 +231,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
loadOutboxReadDate,
copyMessageLink,
openDeleteMessageModal,
addLocalPaidReaction,
openPaidReactionModal,
} = getActions();
const lang = useOldLang();
@ -531,6 +535,22 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
});
const handleSendPaidReaction = useLastCallback(() => {
addLocalPaidReaction({
chatId: message.chatId, messageId: message.id, count: 1,
});
closeMenu();
});
const handlePaidReactionModalOpen = useLastCallback(() => {
openPaidReactionModal({
chatId: message.chatId,
messageId: message.id,
});
closeMenu();
});
const handleReactionPickerOpen = useLastCallback((position: IAnchorPosition) => {
openMessageReactionPicker({ chatId: message.chatId, messageId: message.id, position });
});
@ -577,6 +597,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
availableReactions={availableReactions}
topReactions={topReactions}
defaultTagReactions={defaultTagReactions}
isWithPaidReaction={isWithPaidReaction}
message={message}
isPrivate={isPrivate}
isCurrentUserPremium={isCurrentUserPremium}
@ -644,6 +665,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onClosePoll={openClosePollDialog}
onShowSeenBy={handleOpenSeenByModal}
onToggleReaction={handleToggleReaction}
onSendPaidReaction={handleSendPaidReaction}
onShowPaidReactionModal={handlePaidReactionModalOpen}
onShowReactors={handleOpenReactorListModal}
onReactionPickerOpen={handleReactionPickerOpen}
onTranslate={handleTranslate}
@ -821,6 +844,7 @@ export default memo(withGlobal<OwnProps>(
isInSavedMessages,
isChannel,
canReplyInChat,
isWithPaidReaction: chatFullInfo?.isPaidReactionAvailable,
};
},
)(ContextMenuContainer));

View File

@ -153,7 +153,7 @@ import FakeIcon from '../../common/FakeIcon';
import Icon from '../../common/icons/Icon';
import StarIcon from '../../common/icons/StarIcon';
import MessageText from '../../common/MessageText';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji';
import TopicChip from '../../common/TopicChip';
import Button from '../../ui/Button';
import Album from './Album';
@ -294,6 +294,7 @@ type StateProps = {
canTranscribeVoice?: boolean;
viaBusinessBot?: ApiUser;
effect?: ApiAvailableEffect;
availableStars?: number;
};
type MetaPosition =
@ -414,6 +415,7 @@ const Message: FC<OwnProps & StateProps> = ({
canTranscribeVoice,
viaBusinessBot,
effect,
availableStars,
onIntersectPinnedMessage,
}) => {
const {
@ -1042,6 +1044,7 @@ const Message: FC<OwnProps & StateProps> = ({
noRecentReactors={isChannel}
tags={tags}
isCurrentUserPremium={isPremium}
availableStars={availableStars}
/>
);
}
@ -1649,6 +1652,7 @@ const Message: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersectionForPlaying}
noRecentReactors={isChannel}
tags={tags}
availableStars={availableStars}
/>
)}
</div>
@ -1798,6 +1802,8 @@ export default memo(withGlobal<OwnProps>(
const effect = effectId ? global.availableEffectById[effectId] : undefined;
const { balance: availableStars } = global.stars || {};
return {
theme: selectTheme(global),
forceSenderName,
@ -1884,6 +1890,7 @@ export default memo(withGlobal<OwnProps>(
canTranscribeVoice,
viaBusinessBot,
effect,
availableStars,
};
},
)(Message));

View File

@ -51,6 +51,7 @@ type OwnProps = {
message: ApiMessage | ApiSponsoredMessage;
canSendNow?: boolean;
enabledReactions?: ApiChatReactions;
isWithPaidReaction?: boolean;
reactionsLimit?: number;
canReschedule?: boolean;
canReply?: boolean;
@ -121,6 +122,8 @@ type OwnProps = {
onShowOriginal?: NoneToVoidFunction;
onSelectLanguage?: NoneToVoidFunction;
onToggleReaction?: (reaction: ApiReaction) => void;
onSendPaidReaction?: NoneToVoidFunction;
onShowPaidReactionModal?: NoneToVoidFunction;
onReactionPickerOpen?: (position: IAnchorPosition) => void;
};
@ -138,6 +141,7 @@ const MessageContextMenu: FC<OwnProps> = ({
isPrivate,
isCurrentUserPremium,
enabledReactions,
isWithPaidReaction,
reactionsLimit,
anchor,
targetHref,
@ -201,6 +205,8 @@ const MessageContextMenu: FC<OwnProps> = ({
onShowSeenBy,
onShowReactors,
onToggleReaction,
onSendPaidReaction,
onShowPaidReactionModal,
onCopyMessages,
onAboutAdsClick,
onSponsoredHide,
@ -356,6 +362,9 @@ const MessageContextMenu: FC<OwnProps> = ({
currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined}
reactionsLimit={reactionsLimit}
onToggleReaction={onToggleReaction!}
onSendPaidReaction={onSendPaidReaction}
onShowPaidReactionModal={onShowPaidReactionModal}
isWithPaidReaction={isWithPaidReaction}
isPrivate={isPrivate}
isReady={isReady}
canBuyPremium={canBuyPremium}

View File

@ -11,6 +11,18 @@
--reaction-text-color: var(--text-color-reaction-chosen);
}
&.paid {
--reaction-background: #FFBC2E33 !important;
--reaction-background-hover: #FFBC2E55 !important;
--reaction-text-color: #E98111 !important;
}
&.paid.chosen {
--reaction-background: #FFBC2E !important;
--reaction-background-hover: #FFBC2ECC !important;
--reaction-text-color: #FFFFFF !important;
}
display: flex;
flex-direction: row;
height: 1.875rem;
@ -114,3 +126,17 @@
.disabled {
opacity: 0.5;
}
.paidCounter {
font-family: var(--font-family-rounded);
font-size: 2.5rem;
font-variant-numeric: tabular-nums;
color: #FFBC2E;
position: absolute;
top: -150%;
right: 50%;
transform: translateX(50%);
-webkit-text-stroke: 1px #E58E0D;
z-index: 1;
}

View File

@ -1,8 +1,10 @@
import React, { memo } from '../../../../lib/teact/teact';
import React, { memo, useEffect, useRef } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type {
ApiPeer, ApiReaction, ApiReactionCount,
} from '../../../../api/types';
import type { GlobalState } from '../../../../global/types';
import type { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import { isReactionChosen } from '../../../../global/helpers';
@ -10,28 +12,44 @@ import buildClassName from '../../../../util/buildClassName';
import { formatIntegerCompact } from '../../../../util/textFormat';
import { REM } from '../../../common/helpers/mediaDimensions';
import useSelector from '../../../../hooks/data/useSelector';
import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers';
import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps';
import useLastCallback from '../../../../hooks/useLastCallback';
import usePrevious from '../../../../hooks/usePrevious';
import useShowTransition from '../../../../hooks/useShowTransition';
import AnimatedCounter from '../../../common/AnimatedCounter';
import AvatarList from '../../../common/AvatarList';
import PaidReactionEmoji from '../../../common/reactions/PaidReactionEmoji';
import ReactionAnimatedEmoji from '../../../common/reactions/ReactionAnimatedEmoji';
import Sparkles from '../../../common/Sparkles';
import Button from '../../../ui/Button';
import styles from './ReactionButton.module.scss';
const REACTION_SIZE = 1.25 * REM;
const MAX_SCALE = 3;
type OwnProps = {
chatId: string;
messageId: number;
reaction: ApiReactionCount;
containerId: string;
isOwnMessage?: boolean;
recentReactors?: ApiPeer[];
className?: string;
chosenClassName?: string;
availableStars?: number;
observeIntersection?: ObserveFn;
onClick?: (reaction: ApiReaction) => void;
onPaidClick?: (count: number) => void;
};
function selectAreStarsLoaded(global: GlobalState) {
return Boolean(global.stars);
}
const ReactionButton = ({
reaction,
containerId,
@ -39,36 +57,153 @@ const ReactionButton = ({
recentReactors,
className,
chosenClassName,
availableStars,
chatId,
messageId,
observeIntersection,
onClick,
onPaidClick,
}: OwnProps) => {
const handleClick = useLastCallback(() => {
const { openStarsBalanceModal, resetLocalPaidReactions, openPaidReactionModal } = getActions();
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLButtonElement>(null);
// eslint-disable-next-line no-null/no-null
const counterRef = useRef<HTMLSpanElement>(null);
const animationRef = useRef<Animation>();
const isPaid = reaction.reaction.type === 'paid';
const areStarsLoaded = useSelector(selectAreStarsLoaded);
const handlePaidClick = useLastCallback((count = 1) => {
onPaidClick?.(count);
});
const handleClick = useLastCallback((e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if (reaction.reaction.type === 'paid') {
e.stopPropagation(); // Prevent default message double click behavior
handlePaidClick();
return;
}
onClick?.(reaction.reaction);
});
const {
isContextMenuOpen,
handleBeforeContextMenu,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, reaction.reaction.type !== 'paid', undefined, undefined, undefined, true);
useEffect(() => {
if (isContextMenuOpen) {
openPaidReactionModal({
chatId,
messageId,
});
handleContextMenuClose();
handleContextMenuHide();
}
}, [handleContextMenuClose, handleContextMenuHide, isContextMenuOpen, chatId, messageId]);
useEffectWithPrevDeps(([prevReaction]) => {
const amount = reaction.localAmount;
const button = ref.current;
if (!amount || !button || amount === prevReaction?.localAmount) return;
if (areStarsLoaded && (!availableStars || amount > availableStars)) {
openStarsBalanceModal({
originReaction: {
chatId,
messageId,
amount,
},
});
resetLocalPaidReactions({
chatId,
messageId,
});
return;
}
const currentScale = Number(getComputedStyle(button).scale) || 1;
animationRef.current?.cancel();
// Animate scaling by 20%, and then returning to 1
animationRef.current = button.animate([
{ scale: currentScale },
{ scale: Math.min(currentScale * 1.2, MAX_SCALE), offset: 0.2 },
{ scale: 1 },
], {
duration: 500 * currentScale,
easing: 'ease-out',
});
}, [reaction, availableStars, areStarsLoaded, chatId, messageId]);
const prevAmount = usePrevious(reaction.localAmount);
const {
shouldRender: shouldRenderPaidCounter,
} = useShowTransition({
isOpen: Boolean(reaction.localAmount),
ref: counterRef,
className: 'slow',
withShouldRender: true,
});
return (
<Button
className={buildClassName(
styles.root,
isOwnMessage && styles.own,
isPaid && styles.paid,
isReactionChosen(reaction) && styles.chosen,
isReactionChosen(reaction) && chosenClassName,
className,
)}
size="tiny"
ref={ref}
onMouseDown={handleBeforeContextMenu}
onContextMenu={handleContextMenu}
onClick={handleClick}
>
<ReactionAnimatedEmoji
className={styles.animatedEmoji}
containerId={containerId}
reaction={reaction.reaction}
size={REACTION_SIZE}
observeIntersection={observeIntersection}
/>
{reaction.reaction.type === 'paid' ? (
<>
<PaidReactionEmoji
className={styles.animatedEmoji}
containerId={containerId}
reaction={reaction.reaction}
size={REACTION_SIZE}
localAmount={reaction.localAmount}
observeIntersection={observeIntersection}
/>
<Sparkles preset="reaction" />
{shouldRenderPaidCounter && (
<AnimatedCounter
ref={counterRef}
text={`+${formatIntegerCompact(reaction.localAmount || prevAmount!)}`}
className={styles.paidCounter}
/>
)}
</>
) : (
<ReactionAnimatedEmoji
className={styles.animatedEmoji}
containerId={containerId}
reaction={reaction.reaction}
size={REACTION_SIZE}
observeIntersection={observeIntersection}
/>
)}
{recentReactors?.length ? (
<AvatarList size="mini" peers={recentReactors} />
) : (
<AnimatedCounter text={formatIntegerCompact(reaction.count)} className={styles.counter} />
<AnimatedCounter
text={formatIntegerCompact(reaction.count + (reaction.localAmount || 0))}
className={styles.counter}
/>
)}
</Button>
);

View File

@ -2,18 +2,19 @@ import type { FC } from '../../../../lib/teact/teact';
import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../../global';
import type { IAnchorPosition } from '../../../../types';
import {
type ApiAvailableEffect,
type ApiMessage,
type ApiMessageEntity,
type ApiReaction,
type ApiReactionCustomEmoji,
type ApiSticker,
type ApiStory,
type ApiStorySkipped,
MAIN_THREAD_ID,
import type {
ApiAvailableEffect,
ApiMessage,
ApiMessageEntity,
ApiReaction,
ApiReactionCustomEmoji,
ApiReactionWithPaid,
ApiSticker,
ApiStory,
ApiStorySkipped,
} from '../../../../api/types';
import type { IAnchorPosition } from '../../../../types';
import { MAIN_THREAD_ID } from '../../../../api/types';
import { getReactionKey, getStoryKey, isUserId } from '../../../../global/helpers';
import {
@ -78,7 +79,7 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
}) => {
const {
toggleReaction, closeReactionPicker, sendMessage, showNotification, sendStoryReaction, saveEffectInDraft,
requestEffectInComposer,
requestEffectInComposer, addLocalPaidReaction, openPaidReactionModal,
} = getActions();
const lang = useOldLang();
@ -128,22 +129,40 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
closeReactionPicker();
});
const handleToggleReaction = useLastCallback((reaction: ApiReaction) => {
const handleToggleReaction = useLastCallback((reaction: ApiReactionWithPaid) => {
if (!renderedChatId || !renderedMessageId) {
return;
}
toggleReaction({
chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true,
if (reaction.type === 'paid') {
addLocalPaidReaction({
chatId: renderedChatId, messageId: renderedMessageId, count: 1,
});
} else {
toggleReaction({
chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true,
});
}
closeReactionPicker();
});
const handleReactionContextMenu = useLastCallback((reaction: ApiReactionWithPaid) => {
if (reaction.type !== 'paid') return;
openPaidReactionModal({
chatId: renderedChatId!,
messageId: renderedMessageId!,
});
closeReactionPicker();
});
const handleStoryReactionSelect = useLastCallback((item: ApiReaction | ApiSticker) => {
const reaction = 'id' in item ? { documentId: item.id } : item;
const handleStoryReactionSelect = useLastCallback((item: ApiReactionWithPaid | ApiSticker) => {
if ('type' in item && item.type === 'paid') return; // Not supported for stories
const sticker = 'documentId' in item
? getGlobal().customEmojis.byId[item.documentId] : 'emoticon' in item ? undefined : item;
const reaction = 'id' in item ? { type: 'custom', documentId: item.id } as const : item;
const sticker = 'type' in item && item.type === 'custom' ? getGlobal().customEmojis.byId[item.documentId]
: 'id' in item ? item : undefined;
if (sticker && !sticker.isFree && !isCurrentUserPremium) {
showNotification({
@ -175,7 +194,7 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
let text: string | undefined;
let entities: ApiMessageEntity[] | undefined;
if ('emoticon' in item) {
if ('type' in item && item.type === 'emoji') {
text = item.emoticon;
} else {
const customEmojiMessage = parseHtmlAsFormattedText(buildCustomEmojiHtml(sticker!));
@ -193,7 +212,7 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
if (chatId) saveEffectInDraft({ chatId, threadId: MAIN_THREAD_ID, effectId });
if (effectId) requestEffectInComposer({ });
if (effectId) requestEffectInComposer({});
closeReactionPicker();
});
@ -253,12 +272,14 @@ const ReactionPicker: FC<OwnProps & StateProps> = ({
isTranslucent={isTranslucent}
onCustomEmojiSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleCustomReaction}
onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction}
onReactionContext={handleReactionContextMenu}
/>
{!shouldUseFullPicker && Boolean(renderedChatId) && (
<ReactionPickerLimited
chatId={renderedChatId}
loadAndPlay={isOpen}
onReactionSelect={renderedStoryId ? handleStoryReactionSelect : handleToggleReaction}
onReactionContext={handleReactionContextMenu}
selectedReactionIds={selectedReactionIds}
message={message}
/>
@ -285,7 +306,7 @@ export default memo(withGlobal<OwnProps>((global): StateProps => {
const areSomeReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'some';
const { maxUniqueReactions } = global.appConfig || {};
const areAllReactionsAllowed = chatFullInfo?.enabledReactions?.type === 'all'
&& chatFullInfo?.enabledReactions?.areCustomAllowed;
&& chatFullInfo?.enabledReactions?.areCustomAllowed;
const currentReactions = message?.reactions?.results;
const shouldUseCurrentReactions = Boolean(maxUniqueReactions && currentReactions

View File

@ -8,6 +8,7 @@ import { withGlobal } from '../../../../global';
import type {
ApiAvailableReaction, ApiChatReactions, ApiMessage,
ApiReaction,
ApiReactionWithPaid,
} from '../../../../api/types';
import {
@ -20,22 +21,24 @@ import { REM } from '../../../common/helpers/mediaDimensions';
import useAppLayout from '../../../../hooks/useAppLayout';
import useWindowSize from '../../../../hooks/window/useWindowSize';
import ReactionEmoji from '../../../common/ReactionEmoji';
import ReactionEmoji from '../../../common/reactions/ReactionEmoji';
import styles from './ReactionPickerLimited.module.scss';
type OwnProps = {
chatId: string;
loadAndPlay: boolean;
onReactionSelect?: (reaction: ApiReaction) => void;
selectedReactionIds?: string[];
message?: ApiMessage;
onReactionSelect: (reaction: ApiReactionWithPaid) => void;
onReactionContext?: (reaction: ApiReactionWithPaid) => void;
};
type StateProps = {
enabledReactions?: ApiChatReactions;
availableReactions?: ApiAvailableReaction[];
topReactions: ApiReaction[];
isWithPaidReaction?: boolean;
canAnimate?: boolean;
isSavedMessages?: boolean;
reactionsLimit?: number;
@ -56,9 +59,11 @@ const ReactionPickerLimited: FC<OwnProps & StateProps> = ({
availableReactions,
topReactions,
selectedReactionIds,
onReactionSelect,
isWithPaidReaction,
message,
reactionsLimit,
onReactionSelect,
onReactionContext,
}) => {
// eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
@ -74,18 +79,34 @@ const ReactionPickerLimited: FC<OwnProps & StateProps> = ({
const allAvailableReactions = useMemo(() => {
if (shouldUseCurrentReactions) {
return currentReactions.map(({ reaction }) => reaction);
const reactions = currentReactions.map(({ reaction }) => reaction);
if (isWithPaidReaction) {
reactions.unshift({ type: 'paid' });
}
return reactions;
}
if (!enabledReactions) {
return [];
}
if (enabledReactions.type === 'all') {
return sortReactions((availableReactions || []).map(({ reaction }) => reaction), topReactions);
const reactionsToSort: ApiReactionWithPaid[] = (availableReactions || []).map(({ reaction }) => reaction);
if (isWithPaidReaction) {
reactionsToSort.unshift({ type: 'paid' });
}
return sortReactions(reactionsToSort, topReactions);
}
return sortReactions(enabledReactions.allowed, topReactions);
}, [availableReactions, enabledReactions, topReactions, shouldUseCurrentReactions, currentReactions]);
const reactionsToSort: ApiReactionWithPaid[] = enabledReactions.allowed;
if (isWithPaidReaction) {
reactionsToSort.unshift({ type: 'paid' });
}
return sortReactions(reactionsToSort, topReactions);
}, [
availableReactions, enabledReactions, topReactions, shouldUseCurrentReactions, currentReactions, isWithPaidReaction,
]);
const pickerHeight = useMemo(() => {
const pickerWidth = Math.min(MODAL_MAX_WIDTH_REM * REM, windowWidth);
@ -118,6 +139,7 @@ const ReactionPickerLimited: FC<OwnProps & StateProps> = ({
loadAndPlay={loadAndPlay}
availableReactions={availableReactions}
onClick={onReactionSelect!}
onContextMenu={onReactionContext}
sharedCanvasRef={sharedCanvasRef}
sharedCanvasHqRef={sharedCanvasHqRef}
/>
@ -134,13 +156,14 @@ export default memo(withGlobal<OwnProps>(
const { availableReactions, topReactions } = global.reactions;
const { maxUniqueReactions } = global.appConfig || {};
const { enabledReactions } = selectChatFullInfo(global, chatId) || {};
const { enabledReactions, isPaidReactionAvailable } = selectChatFullInfo(global, chatId) || {};
return {
enabledReactions,
availableReactions,
topReactions,
reactionsLimit: maxUniqueReactions,
isWithPaidReaction: isPaidReactionAvailable,
};
},
)(ReactionPickerLimited));

View File

@ -3,7 +3,12 @@ import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type {
ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount,
ApiAvailableReaction,
ApiChatReactions,
ApiReaction,
ApiReactionCount,
ApiReactionCustomEmoji,
ApiReactionPaid,
} from '../../../../api/types';
import type { IAnchorPosition } from '../../../../types';
@ -22,6 +27,8 @@ import ReactionSelectorReaction from './ReactionSelectorReaction';
import './ReactionSelector.scss';
type RenderableReactions = (ApiAvailableReaction | ApiReactionCustomEmoji | ApiReactionPaid)[];
type OwnProps = {
enabledReactions?: ApiChatReactions;
isPrivate?: boolean;
@ -39,8 +46,11 @@ type OwnProps = {
isInSavedMessages?: boolean;
isInStoryViewer?: boolean;
isForEffects?: boolean;
isWithPaidReaction?: boolean;
onClose?: NoneToVoidFunction;
onToggleReaction: (reaction: ApiReaction) => void;
onSendPaidReaction?: NoneToVoidFunction;
onShowPaidReactionModal?: NoneToVoidFunction;
onShowMore: (position: IAnchorPosition) => void;
};
@ -64,8 +74,11 @@ const ReactionSelector: FC<OwnProps> = ({
isInStoryViewer,
isForEffects,
effectReactions,
isWithPaidReaction,
onClose,
onToggleReaction,
onSendPaidReaction,
onShowPaidReactionModal,
onShowMore,
}) => {
const { openPremiumModal } = getActions();
@ -87,8 +100,8 @@ const ReactionSelector: FC<OwnProps> = ({
return allAvailableReactions?.map((reaction) => reaction.reaction);
})();
const filteredReactions = reactions?.map((reaction) => {
const isCustomReaction = 'documentId' in reaction;
const filteredReactions: RenderableReactions = reactions?.map((reaction) => {
const isCustomReaction = reaction.type === 'custom';
const availableReaction = allAvailableReactions?.find((r) => isSameReaction(r.reaction, reaction));
if (isForEffects) return availableReaction;
@ -103,11 +116,14 @@ const ReactionSelector: FC<OwnProps> = ({
return isCustomReaction ? reaction : availableReaction;
}).filter(Boolean) || [];
return sortReactions(filteredReactions, topReactions);
const sortedReactions = sortReactions(filteredReactions, topReactions);
if (isWithPaidReaction) {
sortedReactions.unshift({ type: 'paid' });
}
return sortedReactions;
}, [
allAvailableReactions, currentReactions, defaultTagReactions, enabledReactions, isInSavedMessages, isPrivate,
topReactions, isForEffects, effectReactions, shouldUseCurrentReactions,
topReactions, isForEffects, effectReactions, shouldUseCurrentReactions, isWithPaidReaction,
]);
const reactionsToRender = useMemo(() => {
@ -196,6 +212,8 @@ const ReactionSelector: FC<OwnProps> = ({
key={getReactionKey(reaction)}
isReady={isReady}
onToggleReaction={onToggleReaction}
onSendPaidReaction={onSendPaidReaction}
onShowPaidReactionModal={onShowPaidReactionModal}
reaction={reaction}
noAppearAnimation={!canPlayAnimatedEmojis}
chosen={userReactionIndexes.has(i)}

View File

@ -1,11 +1,16 @@
import type { FC } from '../../../../lib/teact/teact';
import React, { memo } from '../../../../lib/teact/teact';
import React, { memo, useEffect, useRef } from '../../../../lib/teact/teact';
import type { ApiReaction, ApiReactionCustomEmoji } from '../../../../api/types';
import type { ApiReaction, ApiReactionCustomEmoji, ApiReactionPaid } from '../../../../api/types';
import buildClassName from '../../../../util/buildClassName';
import { LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import { REM } from '../../../common/helpers/mediaDimensions';
import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers';
import useLastCallback from '../../../../hooks/useLastCallback';
import AnimatedIcon from '../../../common/AnimatedIcon';
import CustomEmoji from '../../../common/CustomEmoji';
import Icon from '../../../common/icons/Icon';
@ -14,13 +19,15 @@ import styles from './ReactionSelectorReaction.module.scss';
const REACTION_SIZE = 2 * REM;
type OwnProps = {
reaction: ApiReactionCustomEmoji;
reaction: ApiReactionCustomEmoji | ApiReactionPaid;
chosen?: boolean;
isReady?: boolean;
noAppearAnimation?: boolean;
style?: string;
isLocked?: boolean;
onToggleReaction: (reaction: ApiReaction) => void;
onSendPaidReaction?: NoneToVoidFunction;
onShowPaidReactionModal?: NoneToVoidFunction;
};
const ReactionSelectorCustomReaction: FC<OwnProps> = ({
@ -31,27 +38,64 @@ const ReactionSelectorCustomReaction: FC<OwnProps> = ({
style,
isLocked,
onToggleReaction,
onSendPaidReaction,
onShowPaidReactionModal,
}) => {
function handleClick() {
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
const handleClick = useLastCallback(() => {
if (reaction.type === 'paid') {
onSendPaidReaction?.();
return;
}
onToggleReaction(reaction);
}
});
const {
isContextMenuOpen,
handleBeforeContextMenu,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, reaction.type !== 'paid', undefined, undefined, undefined, true);
useEffect(() => {
if (isContextMenuOpen) {
onShowPaidReactionModal?.();
handleContextMenuClose();
handleContextMenuHide();
}
}, [handleContextMenuClose, onShowPaidReactionModal, handleContextMenuHide, isContextMenuOpen]);
return (
<div
className={buildClassName(
styles.root,
styles.custom,
chosen && styles.chosen,
chosen && reaction.type !== 'paid' && styles.chosen,
!noAppearAnimation && isReady && styles.customAnimated,
noAppearAnimation && styles.visible,
)}
ref={ref}
style={style}
onClick={handleClick}
onMouseDown={handleBeforeContextMenu}
onContextMenu={handleContextMenu}
>
<CustomEmoji
documentId={reaction.documentId}
size={REACTION_SIZE}
/>
{reaction.type === 'paid' ? (
<AnimatedIcon
tgsUrl={LOCAL_TGS_URLS.StarReaction}
size={REACTION_SIZE}
noLoop={false}
/>
) : (
<CustomEmoji
documentId={reaction.documentId}
size={REACTION_SIZE}
/>
)}
{isLocked && (
<Icon className={styles.lock} name="lock-badge" />
)}

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../../lib/teact/teact';
import React, { memo, useMemo } from '../../../../lib/teact/teact';
import React, { memo, useEffect, useMemo } from '../../../../lib/teact/teact';
import { getActions, getGlobal } from '../../../../global';
import type {
@ -17,6 +17,7 @@ import { selectPeer } from '../../../../global/selectors';
import buildClassName from '../../../../util/buildClassName';
import { getMessageKey } from '../../../../util/keys/messageKey';
import useEffectOnce from '../../../../hooks/useEffectOnce';
import useLastCallback from '../../../../hooks/useLastCallback';
import useOldLang from '../../../../hooks/useOldLang';
@ -35,9 +36,11 @@ type OwnProps = {
isCurrentUserPremium?: boolean;
observeIntersection?: ObserveFn;
noRecentReactors?: boolean;
availableStars?: number;
};
const MAX_RECENT_AVATARS = 3;
const PAID_SEND_DELAY = 5000;
const Reactions: FC<OwnProps> = ({
message,
@ -49,12 +52,16 @@ const Reactions: FC<OwnProps> = ({
noRecentReactors,
isCurrentUserPremium,
tags,
availableStars,
}) => {
const {
toggleReaction,
addLocalPaidReaction,
updateMiddleSearch,
performMiddleSearch,
openPremiumModal,
resetLocalPaidReactions,
showNotification,
} = getActions();
const lang = useOldLang();
@ -109,7 +116,7 @@ const Reactions: FC<OwnProps> = ({
return;
}
updateMiddleSearch({ chatId: message.chatId, threadId, update: { savedTag: reaction } });
updateMiddleSearch({ chatId: message.chatId, threadId, update: { savedTag: reaction as ApiReaction } });
performMiddleSearch({ chatId: message.chatId, threadId });
return;
}
@ -121,6 +128,40 @@ const Reactions: FC<OwnProps> = ({
});
});
const paidLocalCount = useMemo(() => results.find((r) => r.reaction.type === 'paid')?.localAmount || 0, [results]);
const handlePaidClick = useLastCallback((count: number) => {
addLocalPaidReaction({
chatId: message.chatId,
messageId: message.id,
count,
});
});
useEffect(() => {
if (!paidLocalCount) return;
showNotification({
localId: getMessageKey(message),
title: lang('StarsSentTitle'),
message: lang('StarsSentText', paidLocalCount),
actionText: lang('StarsSentUndo'),
cacheBreaker: paidLocalCount.toString(),
action: {
action: 'resetLocalPaidReactions',
payload: { chatId: message.chatId, messageId: message.id },
},
dismissAction: {
action: 'sendPaidReaction',
payload: { chatId: message.chatId, messageId: message.id },
},
duration: PAID_SEND_DELAY,
shouldShowTimer: true,
disableClickDismiss: true,
icon: 'star',
});
}, [lang, message, paidLocalCount]);
const handleRemoveReaction = useLastCallback((reaction: ApiReaction) => {
toggleReaction({
chatId: message.chatId,
@ -129,6 +170,14 @@ const Reactions: FC<OwnProps> = ({
});
});
// Reset paid reactions on unmount
useEffectOnce(() => () => {
resetLocalPaidReactions({
chatId: message.chatId,
messageId: message.id,
});
});
return (
<div
className={buildClassName('Reactions', isOutside && 'is-outside')}
@ -146,7 +195,7 @@ const Reactions: FC<OwnProps> = ({
containerId={messageKey}
isOwnMessage={message.isOutgoing}
isChosen={isChosen}
reaction={reaction.reaction}
reaction={reaction.reaction as ApiReaction}
tag={tag}
withContextMenu={isCurrentUserPremium}
onClick={handleClick}
@ -156,6 +205,8 @@ const Reactions: FC<OwnProps> = ({
) : (
<ReactionButton
key={reactionKey}
chatId={message.chatId}
messageId={message.id}
className="message-reaction"
chosenClassName="chosen"
containerId={messageKey}
@ -163,7 +214,9 @@ const Reactions: FC<OwnProps> = ({
recentReactors={recentReactors}
reaction={reaction}
onClick={handleClick}
onPaidClick={handlePaidClick}
observeIntersection={observeIntersection}
availableStars={availableStars}
/>
)
))}

View File

@ -1,7 +1,10 @@
import React, { memo, useRef } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ApiReaction, ApiSavedReactionTag } from '../../../../api/types';
import type {
ApiReaction,
ApiSavedReactionTag,
} from '../../../../api/types';
import type { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import buildClassName from '../../../../util/buildClassName';
@ -90,7 +93,7 @@ const SavedTagButton = ({
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, !withContextMenu);
} = useContextMenuHandlers(ref, !withContextMenu, undefined, undefined, undefined, true);
const getTriggerElement = useLastCallback(() => ref.current);
const getRootElement = useLastCallback(() => document.body);

View File

@ -14,6 +14,7 @@ import GiftCodeModal from './giftcode/GiftCodeModal.async';
import InviteViaLinkModal from './inviteViaLink/InviteViaLinkModal.async';
import MapModal from './map/MapModal.async';
import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async';
import PaidReactionModal from './paidReaction/PaidReactionModal.async';
import ReportAdModal from './reportAd/ReportAdModal.async';
import StarsBalanceModal from './stars/StarsBalanceModal.async';
import StarsPaymentModal from './stars/StarsPaymentModal.async';
@ -35,6 +36,8 @@ type ModalKey = keyof Pick<TabState,
'reportAdModal' |
'starsBalanceModal' |
'isStarPaymentModalOpen' |
'starsTransactionModal' |
'paidReactionModal' |
'webApps' |
'starsTransactionModal'
>;
@ -66,6 +69,7 @@ const MODALS: ModalRegistry = {
isStarPaymentModalOpen: StarsPaymentModal,
starsBalanceModal: StarsBalanceModal,
starsTransactionModal: StarsTransactionInfoModal,
paidReactionModal: PaidReactionModal,
};
const MODAL_KEYS = Object.keys(MODALS) as ModalKey[];
const MODAL_ENTRIES = Object.entries(MODALS) as Entries<ModalRegistry>;

View File

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

View File

@ -0,0 +1,70 @@
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
font-size: 1.5rem;
margin-bottom: 0;
margin-top: 1.25rem;
}
.slider {
margin-top: 1.5rem;
flex-shrink: 0;
}
.description {
margin-bottom: 1.5rem;
}
.title, .description {
text-align: center;
}
.modalBalance {
position: absolute;
top: 0.75rem;
right: 1.25rem;
z-index: 3;
}
.topLabel {
background-image: var(--stars-gradient);
color: var(--color-white);
border-radius: 1rem;
padding: 0.25rem 0.75rem;
}
.top {
display: flex;
justify-content: space-around;
margin-top: 1rem;
}
.topBadge {
background-image: var(--stars-gradient);
}
.buttonStar {
margin-inline-start: 0.25rem;
}
.topPeer {
overflow: hidden;
flex-basis: 0;
flex-grow: 1;
}
.checkbox {
align-self: center;
margin-bottom: 1.5rem;
}
.disclaimer {
font-size: 0.875rem;
align-self: center;
color: var(--color-text-secondary);
margin-bottom: 0;
}

View File

@ -0,0 +1,247 @@
import React, {
memo, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiChat, ApiMessage, ApiUser } from '../../../api/types';
import type { TabState } from '../../../global/types';
import type { CustomPeer } from '../../../types';
import { getChatTitle, getUserFullName } from '../../../global/helpers';
import { selectChat, selectChatMessage, selectUser } from '../../../global/selectors';
import { formatInteger } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Icon from '../../common/icons/Icon';
import PeerBadge from '../../common/PeerBadge';
import SafeLink from '../../common/SafeLink';
import Button from '../../ui/Button';
import Checkbox from '../../ui/Checkbox';
import Modal from '../../ui/Modal';
import Separator from '../../ui/Separator';
import BalanceBlock from '../stars/BalanceBlock';
import StarSlider from './StarSlider';
import styles from './PaidReactionModal.module.scss';
export type OwnProps = {
modal: TabState['paidReactionModal'];
};
type StateProps = {
message?: ApiMessage;
chat?: ApiChat;
maxAmount: number;
starBalance?: number;
defaultPrivacy?: boolean;
};
type ReactorData = {
amount: number;
localAmount: number;
isMe?: boolean;
isAnonymous?: boolean;
user?: ApiUser;
};
const MAX_TOP_REACTORS = 3;
const DEFAULT_STARS_AMOUNT = 50;
const MAX_REACTION_AMOUNT = 2500;
const ANONYMOUS_PEER: CustomPeer = {
avatarIcon: 'author-hidden',
customPeerAvatarColor: '#9eaab5',
isCustomPeer: true,
titleKey: 'StarsReactionAnonymous',
};
const PaidReactionModal = ({
modal,
chat,
message,
maxAmount,
starBalance,
defaultPrivacy,
}: OwnProps & StateProps) => {
const { closePaidReactionModal, addLocalPaidReaction } = getActions();
const [starsAmount, setStarsAmount] = useState(DEFAULT_STARS_AMOUNT);
const [isTouched, markTouched, unmarkTouched] = useFlag();
const [shouldShowUp, setShouldShowUp] = useState(true);
const oldLang = useOldLang();
const lang = useLang();
const handleAnonimityChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setShouldShowUp(e.target.checked);
});
const handleAmountChange = useLastCallback((value: number) => {
setStarsAmount(value);
markTouched();
});
useEffect(() => {
if (!modal) {
unmarkTouched();
}
}, [modal]);
useEffect(() => {
const currentReactor = message?.reactions?.topReactors?.find((reactor) => reactor.isMe);
if (currentReactor) {
setShouldShowUp(!currentReactor.isAnonymous);
return;
}
setShouldShowUp(defaultPrivacy || true);
}, [defaultPrivacy, message?.reactions?.topReactors]);
const handleSend = useLastCallback(() => {
if (!modal) return;
addLocalPaidReaction({
chatId: modal.chatId,
messageId: modal.messageId,
count: starsAmount,
isPrivate: !shouldShowUp,
});
closePaidReactionModal();
});
const topReactors = useMemo(() => {
const global = getGlobal();
const all = message?.reactions?.topReactors;
if (!all) {
return undefined;
}
const result: ReactorData[] = [];
let hasMe = false;
all.forEach((reactor) => {
const user = reactor.peerId ? selectUser(global, reactor.peerId) : undefined;
if (!user && !reactor.isAnonymous && !reactor.isMe) return;
if (reactor.isMe) {
hasMe = true;
}
result.push({
amount: reactor.count,
localAmount: reactor.isMe && isTouched ? starsAmount : 0,
isMe: reactor.isMe,
isAnonymous: reactor.isAnonymous,
user,
});
});
if (!hasMe && isTouched) {
const me = selectUser(global, global.currentUserId!);
result.push({
amount: 0,
localAmount: starsAmount,
isMe: true,
user: me,
});
}
result.sort((a, b) => (b.amount + b.localAmount) - (a.amount + a.localAmount));
return result.slice(0, MAX_TOP_REACTORS);
}, [isTouched, message?.reactions?.topReactors, starsAmount]);
const chatTitle = chat && getChatTitle(oldLang, chat);
return (
<Modal
isOpen={Boolean(modal)}
onClose={closePaidReactionModal}
isSlim
hasAbsoluteCloseButton
contentClassName={styles.content}
>
{starBalance !== undefined && <BalanceBlock balance={starBalance} className={styles.modalBalance} />}
<StarSlider
className={styles.slider}
defaultValue={DEFAULT_STARS_AMOUNT}
maxValue={maxAmount}
onChange={handleAmountChange}
/>
<h3 className={styles.title}>{oldLang('StarsReactionTitle')}</h3>
<div className={styles.description}>
{renderText(oldLang('StarsReactionText', chatTitle), ['simple_markdown', 'emoji'])}
</div>
<Separator>
{topReactors && <div className={styles.topLabel}>{oldLang('StarsReactionTopSenders')}</div>}
</Separator>
{topReactors && (
<div className={styles.top}>
{topReactors.map((reactor) => {
const countText = formatInteger(reactor.amount + reactor.localAmount);
const peer = (reactor.isAnonymous || !reactor.user || (reactor.isMe && !shouldShowUp))
? ANONYMOUS_PEER : reactor.user;
const text = 'isCustomPeer' in peer ? oldLang(peer.titleKey) : getUserFullName(peer);
return (
<PeerBadge
className={styles.topPeer}
key={`${reactor.user?.id || 'anonymous'}-${countText}`}
peer={peer}
badgeText={countText}
badgeIcon="star"
badgeClassName={styles.topBadge}
text={text}
/>
);
})}
</div>
)}
<Checkbox
className={styles.checkbox}
checked={shouldShowUp}
onChange={handleAnonimityChange}
label={oldLang('StarsReactionShowMeInTopSenders')}
/>
<Button
size="smaller"
onClick={handleSend}
>
{lang('SendPaidReaction', { amount: starsAmount }, {
withNodes: true,
specialReplacement: {
'⭐️': <Icon className={styles.buttonStar} name="star" />,
},
})}
</Button>
<p className={styles.disclaimer}>
{lang('StarsReactionTerms', {
link: <SafeLink text={lang('StarsReactionLinkText')} url={lang('StarsReactionLink')} />,
}, {
withNodes: true,
})}
</p>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const chat = modal && selectChat(global, modal.chatId);
const message = modal && selectChatMessage(global, modal.chatId, modal.messageId);
const starBalance = global.stars?.balance;
const maxAmount = global.appConfig?.paidReactionMaxAmount || MAX_REACTION_AMOUNT;
const defaultPrivacy = global.settings.paidReactionPrivacy;
return {
chat,
message,
starBalance,
maxAmount,
defaultPrivacy,
};
},
)(PaidReactionModal));

View File

@ -0,0 +1,137 @@
@use "../../../styles/mixins";
.root {
--_size: 1.875rem;
--progress: 0;
position: relative;
padding-top: 4rem;
overflow-x: hidden;
@include mixins.reset-range();
}
.slider {
height: var(--_size) !important;
margin-bottom: 0 !important;
cursor: pointer;
&::-webkit-slider-runnable-track {
height: var(--_size);
border-radius: 1rem;
background-color: var(--color-background-secondary);
}
&::-moz-range-track {
height: var(--_size);
border-radius: 1rem;
background-color: var(--color-background-secondary);
}
&::-webkit-slider-thumb {
height: var(--_size);
width: var(--_size);
background-color: transparent;
border: none;
outline: none;
box-shadow: none;
}
&::-moz-range-thumb {
height: var(--_size);
width: var(--_size);
background-color: transparent;
border: none;
outline: none;
box-shadow: none;
}
}
.sparkles {
left: 0;
bottom: 0;
height: var(--_size);
pointer-events: none;
--_width: calc(var(--progress) * 100% - 1rem);
mask-image: linear-gradient(to right, black var(--_width), transparent calc(var(--_width) + 0.5rem));
color: white;
}
.progress {
position: absolute;
left: 0;
bottom: 0;
height: var(--_size);
pointer-events: none;
border-radius: 1rem;
min-width: var(--_size);
width: calc(var(--_size) + (var(--progress) * (100% - var(--_size))));
background-image: var(--stars-gradient);
&::after {
content: "";
position: absolute;
right: 0.125rem;
top: 0.125rem;
width: 1.625rem;
height: 1.625rem;
border-radius: 50%;
background-color: white;
z-index: 1;
}
}
.floatingBadgeWrapper {
--_min-x: 0;
--_max-x: 100%;
position: absolute;
left: 0;
right: 0;
transform:
translateX(
clamp(
var(--_min-x),
calc(var(--_size) / 2 + var(--progress) * (100% - var(--_size))),
var(--_max-x),
)
);
pointer-events: none;
}
.floatingBadge {
--_speed: 0;
position: absolute;
top: -1rem;
left: 0;
transform: translate(-50%, -100%);
}
.floatingBadgeText {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.5rem 1rem;
border-radius: 2rem;
background-image: var(--stars-gradient);
line-height: 1;
font-size: 1.5rem;
font-weight: 500;
color: white;
white-space: nowrap;
}
.floatingBadgeTriangle {
position: absolute;
left: 50%;
bottom: 0;
transform: translate(-50%, 33%);
z-index: -1;
}

View File

@ -0,0 +1,134 @@
import React, {
memo, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom';
import buildClassName from '../../../util/buildClassName';
import { formatInteger } from '../../../util/textFormat';
import useEffectOnce from '../../../hooks/useEffectOnce';
import useLastCallback from '../../../hooks/useLastCallback';
import useResizeObserver from '../../../hooks/useResizeObserver';
import AnimatedCounter from '../../common/AnimatedCounter';
import Icon from '../../common/icons/Icon';
import Sparkles from '../../common/Sparkles';
import styles from './StarSlider.module.scss';
type OwnProps = {
maxValue: number;
defaultValue: number;
className?: string;
onChange: (value: number) => void;
};
const DEFAULT_POINTS = [50, 100, 500, 1000, 2000, 5000, 10000];
const StarSlider = ({
maxValue,
defaultValue,
className,
onChange,
}: OwnProps) => {
// eslint-disable-next-line no-null/no-null
const floatingBadgeRef = useRef<HTMLDivElement>(null);
const points = useMemo(() => {
const result = [];
for (let i = 0; i < DEFAULT_POINTS.length; i++) {
if (DEFAULT_POINTS[i] < maxValue) {
result.push(DEFAULT_POINTS[i]);
}
if (DEFAULT_POINTS[i] >= maxValue) {
result.push(maxValue);
break;
}
}
return result;
}, [maxValue]);
const [value, setValue] = useState(0);
useEffectOnce(() => {
setValue(getProgress(points, defaultValue));
});
const updateSafeBadgePosition = useLastCallback(() => {
const badge = floatingBadgeRef.current;
if (!badge) return;
const parent = badge.parentElement!;
requestMeasure(() => {
const safeMinX = parent.offsetLeft + badge.offsetWidth / 2;
const safeMaxX = parent.offsetLeft + parent.offsetWidth - badge.offsetWidth / 2;
requestMutation(() => {
parent.style.setProperty('--_min-x', `${safeMinX}px`);
parent.style.setProperty('--_max-x', `${safeMaxX}px`);
});
});
});
useResizeObserver(floatingBadgeRef, updateSafeBadgePosition);
const handleChange = useLastCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(event.currentTarget.value);
setValue(newValue);
onChange(getValue(points, newValue));
});
return (
<div className={buildClassName(styles.root, className)} style={`--progress: ${value / points.length}`}>
<div className={styles.floatingBadgeWrapper}>
<div className={styles.floatingBadge} ref={floatingBadgeRef}>
<div className={styles.floatingBadgeText}>
<Icon name="star" className={styles.floatingBadgeIcon} />
<AnimatedCounter text={formatInteger(getValue(points, value))} />
</div>
<svg className={styles.floatingBadgeTriangle} width="28" height="28" viewBox="0 0 28 28" fill="none">
<defs>
<linearGradient id="StarBadgeTriangle" x1="0" x2="1" y1="0" y2="0">
<stop offset="-50%" stop-color="#FFAA00" />
<stop offset="150%" stop-color="#FFCD3A" />
</linearGradient>
</defs>
<path d="m 28,4 v 9 c 0.0089,7.283278 -3.302215,5.319646 -6.750951,8.589815 l -5.8284,5.82843 c -0.781,0.78105 -2.0474,0.78104 -2.8284,0 L 6.7638083,21.589815 C 2.8288652,17.959047 0.04527024,20.332086 0,13 V 4 C 0,4 0.00150581,0.97697493 3,1 5.3786658,1.018266 22.594519,0.9142007 25,1 c 2.992326,0.1067311 3,3 3,3 z" fill="url(#StarBadgeTriangle)" />
</svg>
</div>
</div>
<div className={styles.progress} />
<Sparkles preset="progress" className={styles.sparkles} />
<input
className={styles.slider}
type="range"
min={0}
max={points.length}
defaultValue={getProgress(points, defaultValue)}
step="any"
onChange={handleChange}
/>
</div>
);
};
function getProgress(points: number[], value: number) {
const pointIndex = points.findIndex((point) => value <= point);
const prevPoint = points[pointIndex - 1] || 1;
const nextPoint = points[pointIndex] || points[points.length - 1];
const progress = (value - prevPoint) / (nextPoint - prevPoint);
return pointIndex + progress;
}
function getValue(points: number[], progress: number) {
const pointIndex = Math.floor(progress);
const prevPoint = points[pointIndex - 1] || 1;
const nextPoint = points[pointIndex] || points[points.length - 1];
const value = prevPoint + (nextPoint - prevPoint) * (progress - pointIndex);
return Math.round(value);
}
export default memo(StarSlider);

View File

@ -1,16 +1,17 @@
import React, {
memo, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiUser } from '../../../api/types';
import type { ApiStarTopupOption } from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers';
import { selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import { getChatTitle, getUserFullName } from '../../../global/helpers';
import { selectChat, selectIsPremiumPurchaseBlocked, selectUser } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
@ -24,6 +25,7 @@ import Modal from '../../ui/Modal';
import TabList, { type TabWithProperties } from '../../ui/TabList';
import Transition from '../../ui/Transition';
import BalanceBlock from './BalanceBlock';
import StarTopupOptionList from './StarTopupOptionList';
import TransactionItem from './transaction/StarsTransactionItem';
import styles from './StarsBalanceModal.module.scss';
@ -44,15 +46,14 @@ export type OwnProps = {
type StateProps = {
starsBalanceState?: GlobalState['stars'];
originPaymentBot?: ApiUser;
canBuyPremium?: boolean;
};
const StarsBalanceModal = ({
modal, starsBalanceState, originPaymentBot, canBuyPremium,
modal, starsBalanceState, canBuyPremium,
}: OwnProps & StateProps) => {
const {
closeStarsBalanceModal, loadStarsTransactions, openStarsGiftingModal, openStarsGiftModal,
closeStarsBalanceModal, loadStarsTransactions, openStarsGiftingModal, openInvoice,
} = getActions();
const { balance, history } = starsBalanceState || {};
@ -62,13 +63,35 @@ const StarsBalanceModal = ({
const [isHeaderHidden, setHeaderHidden] = useState(true);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [areBuyOptionsShown, showBuyOptions, hideBuyOptions] = useFlag();
const isOpen = Boolean(modal && starsBalanceState);
const productStarsPrice = modal?.originPayment?.invoice?.amount;
const starsNeeded = productStarsPrice ? productStarsPrice - (balance || 0) : undefined;
const originBotName = originPaymentBot && getUserFullName(originPaymentBot);
const shouldShowTransactions = Boolean(history?.all?.transactions.length && !modal?.originPayment);
const { originPayment, originReaction } = modal || {};
const ongoingTransactionAmount = originPayment?.invoice?.amount || originReaction?.amount;
const starsNeeded = ongoingTransactionAmount ? ongoingTransactionAmount - (balance || 0) : undefined;
const starsNeededText = useMemo(() => {
if (!starsNeeded || starsNeeded < 0) return undefined;
const global = getGlobal();
if (originReaction) {
const channel = selectChat(global, originReaction.chatId);
if (!channel) return undefined;
return oldLang('StarsNeededTextReactions', getChatTitle(oldLang, channel));
}
if (originPayment) {
const bot = selectUser(global, originPayment.botId!);
if (!bot) return undefined;
return oldLang('StarsNeededText', getUserFullName(bot));
}
return undefined;
}, [oldLang, originPayment, originReaction, starsNeeded]);
const shouldShowTransactions = Boolean(history?.all?.transactions.length && !originPayment && !originReaction);
const shouldSuggestGifting = !originPayment && !originReaction;
useEffect(() => {
if (!isOpen) {
@ -77,6 +100,15 @@ const StarsBalanceModal = ({
}
}, [isOpen]);
useEffect(() => {
if (ongoingTransactionAmount) {
showBuyOptions();
return;
}
hideBuyOptions();
}, [ongoingTransactionAmount]);
const tosText = useMemo(() => {
if (!isOpen) return undefined;
@ -105,8 +137,13 @@ const StarsBalanceModal = ({
openStarsGiftingModal({});
});
const openStarsInfoModalHandler = useLastCallback(() => {
openStarsGiftModal({});
const handleBuyStars = useLastCallback((option: ApiStarTopupOption) => {
openInvoice({
type: 'stars',
stars: option.stars,
currency: option.currency,
amount: option.amount,
});
});
return (
@ -137,19 +174,19 @@ const StarsBalanceModal = ({
</h2>
<div className={styles.description}>
{renderText(
starsNeeded ? oldLang('StarsNeededText', originBotName) : oldLang('TelegramStarsInfo'),
starsNeededText || oldLang('TelegramStarsInfo'),
['simple_markdown', 'emoji'],
)}
</div>
{canBuyPremium && (
{canBuyPremium && !areBuyOptionsShown && (
<Button
className={styles.starButton}
onClick={openStarsInfoModalHandler}
onClick={showBuyOptions}
>
{oldLang('Star.List.BuyMoreStars')}
</Button>
)}
{canBuyPremium && (
{canBuyPremium && !areBuyOptionsShown && shouldSuggestGifting && (
<Button
className={buildClassName(styles.starButton, 'settings-main-menu-star')}
color="translucent"
@ -159,6 +196,13 @@ const StarsBalanceModal = ({
{oldLang('TelegramStarsGift')}
</Button>
)}
{areBuyOptionsShown && starsBalanceState?.topupOptions && (
<StarTopupOptionList
starsNeeded={starsNeeded}
options={starsBalanceState.topupOptions}
onClick={handleBuyStars}
/>
)}
</div>
<div className={styles.secondaryInfo}>
{tosText}
@ -201,13 +245,9 @@ const StarsBalanceModal = ({
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): StateProps => {
const botId = modal?.originPayment?.botId;
const bot = botId ? selectUser(global, botId) : undefined;
(global): StateProps => {
return {
starsBalanceState: global.stars,
originPaymentBot: bot,
canBuyPremium: !selectIsPremiumPurchaseBlocked(global),
};
},

View File

@ -12,8 +12,8 @@
}
.preview {
height: 3rem;
width: 3rem;
height: 2.75rem;
width: 2.75rem;
grid-auto-columns: 0.25rem;
grid-auto-rows: 0.25rem;

View File

@ -50,7 +50,15 @@ const StarsTransactionItem = ({ transaction }: OwnProps) => {
const peer = useSelector(selectOptionalPeer(peerId));
const data = useMemo(() => {
let title = transaction.title || (transaction.extendedMedia ? lang('StarMediaPurchase') : undefined);
let title = transaction.title;
if (transaction.extendedMedia) {
title = lang('StarMediaPurchase');
}
if (transaction.isReaction) {
title = lang('StarsReactionsSent');
}
let description;
let status: string | undefined;
let avatarPeer: ApiPeer | CustomPeer | undefined;

View File

@ -133,7 +133,18 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
const peerId = transaction.peer?.type === 'peer' ? transaction.peer.id : undefined;
const toName = transaction.peer && oldLang(getStarsPeerTitleKey(transaction.peer));
const title = transaction.title || (customPeer ? oldLang(customPeer.titleKey) : undefined);
let title = transaction.title;
if (!title && customPeer) {
title = oldLang(customPeer.titleKey);
}
if (!title && transaction.extendedMedia) {
title = oldLang('StarMediaPurchase');
}
if (!title && transaction.isReaction) {
title = oldLang('StarsReactionsSent');
}
const messageLink = peer && transaction.messageId
? getMessageLink(peer, undefined, transaction.messageId) : undefined;
@ -198,7 +209,7 @@ const StarsTransactionModal: FC<OwnProps & StateProps> = ({
]);
if (messageLink) {
tableData.push([oldLang('Stars.Transaction.Media'), <SafeLink url={messageLink} text={messageLink} />]);
tableData.push([oldLang('Stars.Transaction.Reaction.Post'), <SafeLink url={messageLink} text={messageLink} />]);
}
if (isPrizeStars) {

View File

@ -18,7 +18,7 @@ import { selectChat, selectChatFullInfo } from '../../../global/selectors';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useOldLang from '../../../hooks/useOldLang';
import ReactionStaticEmoji from '../../common/ReactionStaticEmoji';
import ReactionStaticEmoji from '../../common/reactions/ReactionStaticEmoji';
import Checkbox from '../../ui/Checkbox';
import FloatingActionButton from '../../ui/FloatingActionButton';
import RadioGroup from '../../ui/RadioGroup';

View File

@ -4,7 +4,9 @@ import { getActions, getGlobal } from '../../global';
import type { ApiStory } from '../../api/types';
import { HEART_REACTION } from '../../config';
import { getStoryKey, isUserId } from '../../global/helpers';
import {
getReactionKey, getStoryKey, isSameReaction, isUserId,
} from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import useLastCallback from '../../hooks/useLastCallback';
@ -35,8 +37,7 @@ const StoryFooter = ({
const { viewsCount, forwardsCount, reactionsCount } = views || {};
const isChannel = !isUserId(peerId);
const isSentStoryReactionHeart = sentReaction && 'emoticon' in sentReaction
? sentReaction.emoticon === HEART_REACTION.emoticon : false;
const isSentStoryReactionHeart = sentReaction && isSameReaction(sentReaction, HEART_REACTION);
const canForward = Boolean(
(isOut || isChannel)
@ -152,7 +153,7 @@ const StoryFooter = ({
>
{sentReaction && (
<ReactionAnimatedEmoji
key={'documentId' in sentReaction ? sentReaction.documentId : sentReaction.emoticon}
key={getReactionKey(sentReaction)}
containerId={containerId}
reaction={sentReaction}
withEffectOnly={isSentStoryReactionHeart}

View File

@ -17,7 +17,7 @@ import useOldLang from '../../hooks/useOldLang';
import GroupChatInfo from '../common/GroupChatInfo';
import PrivateChatInfo from '../common/PrivateChatInfo';
import ReactionStaticEmoji from '../common/ReactionStaticEmoji';
import ReactionStaticEmoji from '../common/reactions/ReactionStaticEmoji';
import ListItem, { type MenuItemContextAction } from '../ui/ListItem';
import styles from './StoryViewModal.module.scss';

View File

@ -35,11 +35,11 @@
&.slim {
.modal-dialog {
max-width: 25rem;
max-width: 26.25rem;
}
.modal-content {
max-height: min(92vh, 32rem);
max-height: min(92vh, 36rem);
}
}

View File

@ -23,13 +23,10 @@
}
.Notification {
background:
rgba(32, 32, 32, 0.8)
url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj48Y2lyY2xlIGZpbGw9IiNGRkYiIGN4PSIxMiIgY3k9IjEyIiByPSIxMiIvPjxjaXJjbGUgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIiBjeD0iMTIiIGN5PSI2LjUiIHI9IjEuNSIvPjxwYXRoIGQ9Ik0xMiA5LjVjLS41NTIgMC0xIC4zNy0xIC44MjZ2Ny4zNDhjMCAuNDU2LjQ0OC44MjYgMSAuODI2czEtLjM3IDEtLjgyNnYtNy4zNDhjMC0uNDU2LS40NDgtLjgyNi0xLS44MjZ6IiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L2c+PC9zdmc+")
no-repeat 0.9375rem 50%;
background-color: rgba(32, 32, 32, 0.8);
background-size: 1.5rem;
border-radius: var(--border-radius-default);
padding: 0.9375rem 0.9375rem 0.9375rem 3.375rem;
padding: 0.9375rem;
color: #fff;
margin: 0 0.5rem;
display: flex;
@ -58,11 +55,20 @@
font-weight: 500;
}
.Notification-button {
.notification-icon {
font-size: 1.75rem;
margin-inline-end: 0.75rem;
}
.notification-timer {
margin-inline: 0.75rem;
}
.notification-button {
color: var(--color-primary);
font-weight: 500;
text-transform: none;
margin: 0 0.5rem;
margin-inline-start: 0.125rem;
height: 2rem;
width: auto;
}

View File

@ -1,4 +1,4 @@
import type { FC } from '../../lib/teact/teact';
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, {
useCallback,
useEffect,
@ -8,37 +8,55 @@ import React, {
import { getActions } from '../../global';
import type { CallbackAction } from '../../global/types';
import type { TextPart } from '../../types';
import type { IconName } from '../../types/icons';
import { ANIMATION_END_DELAY } from '../../config';
import buildClassName from '../../util/buildClassName';
import captureEscKeyListener from '../../util/captureEscKeyListener';
import useLastCallback from '../../hooks/useLastCallback';
import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated';
import Icon from '../common/icons/Icon';
import Button from './Button';
import Portal from './Portal';
import RoundTimer from './RoundTimer';
import './Notification.scss';
type OwnProps = {
title?: TextPart[];
title?: TeactNode;
containerId?: string;
message: TextPart[];
message: TeactNode;
duration?: number;
onDismiss: () => void;
action?: CallbackAction | CallbackAction[];
actionText?: string;
className?: string;
icon?: IconName;
shouldDisableClickDismiss?: boolean;
dismissAction?: CallbackAction;
shouldShowTimer?: boolean;
cacheBreaker?: string;
onDismiss: NoneToVoidFunction;
};
const DEFAULT_DURATION = 3000;
const ANIMATION_DURATION = 150;
const Notification: FC<OwnProps> = ({
title, className,
message, duration = DEFAULT_DURATION, containerId, onDismiss,
action, actionText,
title,
className,
message,
duration = DEFAULT_DURATION,
containerId,
icon,
action,
actionText,
shouldDisableClickDismiss,
dismissAction,
shouldShowTimer,
cacheBreaker,
onDismiss,
}) => {
const actions = getActions();
@ -47,10 +65,15 @@ const Notification: FC<OwnProps> = ({
const timerRef = useRef<number | undefined>(null);
const { transitionClassNames } = useShowTransitionDeprecated(isOpen);
const closeAndDismiss = useCallback(() => {
const closeAndDismiss = useLastCallback((force?: boolean) => {
if (!force && shouldDisableClickDismiss) return;
setIsOpen(false);
setTimeout(onDismiss, ANIMATION_DURATION + ANIMATION_END_DELAY);
}, [onDismiss]);
if (dismissAction) {
// @ts-ignore
actions[dismissAction.action](dismissAction.payload);
}
});
const handleClick = useCallback(() => {
if (action) {
@ -68,7 +91,7 @@ const Notification: FC<OwnProps> = ({
useEffect(() => (isOpen ? captureEscKeyListener(closeAndDismiss) : undefined), [isOpen, closeAndDismiss]);
useEffect(() => {
timerRef.current = window.setTimeout(closeAndDismiss, duration);
timerRef.current = window.setTimeout(() => closeAndDismiss(true), duration);
return () => {
if (timerRef.current) {
@ -76,18 +99,23 @@ const Notification: FC<OwnProps> = ({
timerRef.current = undefined;
}
};
}, [duration, closeAndDismiss]);
}, [duration, cacheBreaker]); // Reset timer if `cacheBreaker` changes
const handleMouseEnter = useCallback(() => {
const handleMouseEnter = useLastCallback(() => {
if (shouldDisableClickDismiss) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
}, []);
});
const handleMouseLeave = useCallback(() => {
const handleMouseLeave = useLastCallback(() => {
if (shouldDisableClickDismiss) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = window.setTimeout(closeAndDismiss, duration);
}, [duration, closeAndDismiss]);
});
return (
<Portal className="Notification-container" containerId={containerId}>
@ -97,6 +125,7 @@ const Notification: FC<OwnProps> = ({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Icon name={icon || 'info-filled'} className="notification-icon" />
<div className="content">
{title && <div className="notification-title">{title}</div>}
{message}
@ -105,11 +134,14 @@ const Notification: FC<OwnProps> = ({
<Button
color="translucent-white"
onClick={handleClick}
className="Notification-button"
className="notification-button"
>
{actionText}
</Button>
)}
{shouldShowTimer && (
<RoundTimer className="notification-timer" key={cacheBreaker} duration={Math.ceil(duration / 1000)} />
)}
</div>
</Portal>
);

View File

@ -0,0 +1,24 @@
.root {
position: relative;
color: var(--color-primary);
font-weight: 500;
}
.svg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.circle {
stroke: var(--color-primary);
fill: transparent;
stroke-width: 2;
stroke-linecap: round;
transition: stroke-dashoffset 1s linear, stroke 0.2s;
@starting-style {
stroke-dashoffset: 0;
}
}

View File

@ -0,0 +1,60 @@
import React, { memo, useEffect, useState } from '../../lib/teact/teact';
import buildClassName from '../../util/buildClassName';
import { formatCountdownShort } from '../../util/dates/dateFormat';
import useInterval from '../../hooks/schedulers/useInterval';
import useOldLang from '../../hooks/useOldLang';
import AnimatedCounter from '../common/AnimatedCounter';
import styles from './RoundTimer.module.scss';
type OwnProps = {
duration: number;
className?: string;
onEnd?: NoneToVoidFunction;
};
const UPDATE_FREQUENCY = 1000;
const TIMER_RADIUS = 14;
const RoundTimer = ({ duration, className, onEnd }: OwnProps) => {
const [timeLeft, setTimeLeft] = useState(duration);
const lang = useOldLang();
useInterval(
() => setTimeLeft((prev) => prev - 1),
timeLeft > 0 ? UPDATE_FREQUENCY : undefined,
);
useEffect(() => {
if (timeLeft <= 0) {
onEnd?.();
}
}, [timeLeft, onEnd]);
useEffect(() => {
setTimeLeft(duration);
}, [duration]);
return (
<div className={buildClassName(styles.root, className)}>
<svg className={styles.svg} width="32px" height="32px">
<circle
cx="16"
cy="16"
r={TIMER_RADIUS}
transform="rotate(-90, 16, 16)"
pathLength="100"
stroke-dasharray="100"
stroke-dashoffset={100 - ((timeLeft - 1) / duration) * 100} // Show it one step further due to animation
className={styles.circle}
/>
</svg>
<AnimatedCounter className={styles.text} text={formatCountdownShort(lang, timeLeft * 1000)} />
</div>
);
};
export default memo(RoundTimer);

View File

@ -295,6 +295,7 @@ export const COUNTRIES_WITH_12H_TIME_FORMAT = new Set(['AU', 'BD', 'CA', 'CO', '
export const API_CHAT_TYPES = ['bots', 'channels', 'chats', 'users'] as const;
export const HEART_REACTION: ApiReactionEmoji = {
type: 'emoji',
emoticon: '❤',
};

View File

@ -233,8 +233,6 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
} else if (isChatOnlySummary && !chat.isMin) {
actions.requestChatUpdate({ chatId: id });
}
actions.closeStoryViewer({ tabId });
actions.closeStarsBalanceModal({ tabId });
});
addActionHandler('openSavedDialog', (global, actions, payload): ActionReturnType => {

View File

@ -2094,6 +2094,11 @@ addActionHandler('loadFactChecks', async (global, actions, payload): Promise<voi
setGlobal(global);
});
addActionHandler('loadPaidReactionPrivacy', (): ActionReturnType => {
callApi('fetchPaidReactionPrivacy');
return undefined;
});
addActionHandler('loadOutboxReadDate', async (global, actions, payload): Promise<void> => {
const { chatId, messageId } = payload;

View File

@ -1,4 +1,4 @@
import type { ApiReactionEmoji } from '../../../api/types';
import type { ApiError, ApiReactionEmoji } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { ApiMediaFormat } from '../../../api/types';
@ -12,6 +12,7 @@ import * as mediaLoader from '../../../util/mediaLoader';
import requestActionTimeout from '../../../util/requestActionTimeout';
import { callApi } from '../../../api/gramjs';
import {
addPaidReaction,
getDocumentMediaHash,
getReactionKey,
getUserReactions,
@ -92,6 +93,7 @@ addActionHandler('loadAvailableEffects', async (global): Promise<void> => {
for (const effect of effects) {
if (effect.effectAnimationId) {
const reaction: ApiReactionEmoji = {
type: 'emoji',
emoticon: effect.emoticon,
};
reactions.push(reaction);
@ -240,6 +242,71 @@ addActionHandler('toggleReaction', async (global, actions, payload): Promise<voi
}
});
addActionHandler('addLocalPaidReaction', (global, actions, payload): ActionReturnType => {
const {
chatId, messageId, count, isPrivate, tabId = getCurrentTabId(),
} = payload;
const chat = selectChat(global, chatId);
const message = selectChatMessage(global, chatId, messageId);
if (!chat || !message) {
return;
}
const currentReactions = message.reactions?.results || [];
const newReactions = addPaidReaction(currentReactions, count, isPrivate);
global = updateChatMessage(global, message.chatId, message.id, {
reactions: {
...currentReactions,
results: newReactions,
},
});
setGlobal(global);
const messageKey = getMessageKey(message);
if (selectPerformanceSettingsValue(global, 'reactionEffects')) {
actions.startActiveReaction({
containerId: messageKey,
reaction: {
type: 'paid',
},
tabId,
});
}
});
addActionHandler('sendPaidReaction', async (global, actions, payload): Promise<void> => {
const {
chatId, messageId, forcedAmount, tabId = getCurrentTabId(),
} = payload;
const chat = selectChat(global, chatId);
const message = selectChatMessage(global, chatId, messageId);
if (!chat || !message) {
return;
}
const paidReaction = message.reactions?.results?.find((r) => r.reaction.type === 'paid');
const count = forcedAmount || paidReaction?.localAmount || 0;
if (!count) {
return;
}
actions.resetLocalPaidReactions({ chatId, messageId });
try {
await callApi('sendPaidReaction', {
chat,
messageId,
count,
isPrivate: paidReaction?.localIsPrivate,
});
} catch (error) {
if ((error as ApiError).message === 'BALANCE_TOO_LOW') {
actions.openStarsBalanceModal({ originReaction: { chatId, messageId, amount: count }, tabId });
}
}
});
addActionHandler('startActiveReaction', (global, actions, payload): ActionReturnType => {
const { containerId, reaction, tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);

View File

@ -19,6 +19,7 @@ import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey';
import { notifyAboutMessage } from '../../../util/notifications';
import { onTickEnd } from '../../../util/schedulers';
import {
addPaidReaction,
checkIfHasUnreadReactions, getIsSavedDialog, getMessageContent, getMessageText, isActionMessage,
isMessageLocal, isUserId,
} from '../../helpers';
@ -788,6 +789,12 @@ function updateReactions<T extends GlobalState>(
return global;
}
const localPaidReaction = currentReactions?.results.find((r) => r.localAmount);
// Save local count on update, but reset if we sent reaction
if (localPaidReaction?.localAmount) {
reactions.results = addPaidReaction(reactions.results, localPaidReaction.localAmount);
}
global = updateChatMessage(global, chatId, id, { reactions });
if (!isOutgoing) {

View File

@ -172,6 +172,19 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.processPremiumFloodWait({
isUpload: update.isUpload,
});
break;
}
case 'updatePaidReactionPrivacy': {
return {
...global,
settings: {
...global.settings,
paidReactionPrivacy: update.isPrivate,
},
};
setGlobal(global);
break;
}
}

View File

@ -38,6 +38,11 @@ addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionRe
}
actions.hideEffectInComposer({ tabId });
actions.closeStoryViewer({ tabId });
actions.closeStarsBalanceModal({ tabId });
actions.closeStarsBalanceModal({ tabId });
actions.closeStarsTransactionModal({ tabId });
if (!currentMessageList || (
currentMessageList.chatId !== chatId
|| currentMessageList.threadId !== threadId

View File

@ -948,6 +948,20 @@ addActionHandler('openPreviousReportAdModal', (global, actions, payload): Action
}, tabId);
});
addActionHandler('openPaidReactionModal', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
paidReactionModal: { chatId, messageId },
}, tabId);
});
addActionHandler('closePaidReactionModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
paidReactionModal: undefined,
}, tabId);
});
function copyTextForMessages(global: GlobalState, chatId: string, messageIds: number[]) {
const { type: messageListType, threadId } = selectCurrentMessageList(global) || {};
const lang = langProvider.oldTranslate;

View File

@ -305,10 +305,13 @@ addActionHandler('reorderStickerSets', (global, actions, payload): ActionReturnT
addActionHandler('showNotification', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId(), ...notification } = payload;
notification.localId = generateUniqueId();
const hasLocalId = notification.localId;
notification.localId ||= generateUniqueId();
const newNotifications = [...selectTabState(global, tabId).notifications];
const existingNotificationIndex = newNotifications.findIndex((n) => n.message === notification.message);
const existingNotificationIndex = newNotifications.findIndex((n) => (
hasLocalId ? n.localId === notification.localId : n.message === notification.message
));
if (existingNotificationIndex !== -1) {
newNotifications.splice(existingNotificationIndex, 1);
}
@ -522,7 +525,7 @@ addActionHandler('setReactionEffect', (global, actions, payload): ActionReturnTy
chatId, threadId, reaction, tabId = getCurrentTabId(),
} = payload;
const emoticon = reaction && 'emoticon' in reaction && reaction.emoticon;
const emoticon = reaction?.type === 'emoji' && reaction.emoticon;
if (!emoticon) return;
const effect = Object.values(global.availableEffectById)

View File

@ -12,7 +12,10 @@ addActionHandler('closePaymentModal', (global, actions, payload): ActionReturnTy
const { tabId = getCurrentTabId() } = payload || {};
const payment = selectTabState(global, tabId).payment;
const status = payment.status || 'cancelled';
const originPayment = selectTabState(global, tabId).starsBalanceModal?.originPayment;
const starsBalanceModal = selectTabState(global, tabId).starsBalanceModal;
const originPayment = starsBalanceModal?.originPayment;
const originReaction = starsBalanceModal?.originReaction;
global = clearPayment(global, tabId);
global = closeInvoice(global, tabId);
global = updateTabState(global, {
@ -20,7 +23,7 @@ addActionHandler('closePaymentModal', (global, actions, payload): ActionReturnTy
...selectTabState(global, tabId).payment,
status,
},
...(originPayment && {
...((originPayment || originReaction) && {
starsBalanceModal: undefined,
}),
}, tabId);
@ -32,6 +35,16 @@ addActionHandler('closePaymentModal', (global, actions, payload): ActionReturnTy
isStarPaymentModalOpen: true,
}, tabId);
}
// Send reaction
if (originReaction) {
actions.sendPaidReaction({
chatId: originReaction.chatId,
messageId: originReaction.messageId,
forcedAmount: originReaction.amount,
tabId,
});
}
return global;
});
@ -56,13 +69,17 @@ addActionHandler('closeGiftCodeModal', (global, actions, payload): ActionReturnT
});
addActionHandler('openStarsBalanceModal', (global, actions, payload): ActionReturnType => {
const { originPayment, tabId = getCurrentTabId() } = payload || {};
const { originPayment, originReaction, tabId = getCurrentTabId() } = payload || {};
global = clearPayment(global, tabId);
// Always refresh status on opening
actions.loadStarStatus();
return updateTabState(global, {
starsBalanceModal: {
originPayment,
originReaction,
},
}, tabId);
});

View File

@ -1,9 +1,11 @@
import type { ActionReturnType } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { getMessageKey } from '../../../util/keys/messageKey';
import { addActionHandler } from '../../index';
import { updateChatMessage } from '../../reducers';
import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors';
import { selectChatMessage, selectTabState } from '../../selectors';
addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionReturnType => {
const {
@ -32,7 +34,7 @@ addActionHandler('openMessageReactionPicker', (global, actions, payload): Action
messageId,
position,
tabId = getCurrentTabId(),
} = payload!;
} = payload;
return updateTabState(global, {
reactionPicker: {
@ -50,7 +52,7 @@ addActionHandler('openStoryReactionPicker', (global, actions, payload): ActionRe
position,
sendAsMessage,
tabId = getCurrentTabId(),
} = payload!;
} = payload;
return updateTabState(global, {
reactionPicker: {
@ -67,7 +69,7 @@ addActionHandler('openEffectPicker', (global, actions, payload): ActionReturnTyp
position,
chatId,
tabId = getCurrentTabId(),
} = payload!;
} = payload;
return updateTabState(global, {
reactionPicker: {
@ -93,3 +95,43 @@ addActionHandler('closeReactionPicker', (global, actions, payload): ActionReturn
},
}, tabId);
});
addActionHandler('resetLocalPaidReactions', (global, actions, payload): ActionReturnType => {
const { chatId, messageId } = payload;
const message = selectChatMessage(global, chatId, messageId);
if (!message) {
return undefined;
}
const { reactions } = message;
if (!reactions) {
return undefined;
}
const updatedResults = reactions.results.map((reaction) => {
if (reaction.localAmount) {
if (!reaction.count) return undefined;
return {
...reaction,
localAmount: undefined,
};
}
return reaction;
}).filter(Boolean);
Object.values(global.byTabId)
.forEach(({ id: tabId }) => {
actions.dismissNotification({
localId: getMessageKey(message),
tabId,
});
});
return updateChatMessage(global, chatId, messageId, {
reactions: {
...reactions,
results: updatedResults,
},
});
});

View File

@ -245,6 +245,11 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
if (!cached.topBotApps) {
cached.topBotApps = initialState.topBotApps;
}
if (!cached.reactions.defaultTags?.[0]?.type) {
cached.reactions = initialState.reactions;
}
if (!cached.users.commonChatsById) {
cached.users.commonChatsById = initialState.users.commonChatsById;
}
@ -523,7 +528,8 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
const cleanedById = Object.values(byId).reduce((acc, message) => {
if (!message) return acc;
const cleanedMessage = omitLocalMedia(message);
let cleanedMessage = omitLocalMedia(message);
cleanedMessage = omitLocalPaidReactions(cleanedMessage);
acc[message.id] = cleanedMessage;
return acc;
}, {} as Record<number, ApiMessage>);
@ -540,6 +546,25 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
};
}
function omitLocalPaidReactions(message: ApiMessage): ApiMessage {
if (!message.reactions?.results.length) return message;
return {
...message,
reactions: {
...message.reactions,
results: message.reactions.results.map((reaction) => {
if (reaction.localAmount) {
return {
...reaction,
localAmount: undefined,
};
}
return reaction;
}),
},
};
}
function omitLocalMedia(message: ApiMessage): ApiMessage {
const {
photo, video, document, sticker,

View File

@ -6,6 +6,7 @@ import type {
ApiReactionCount,
ApiReactionKey,
ApiReactions,
ApiReactionWithPaid,
} from '../../api/types';
import type { GlobalState } from '../types';
@ -20,18 +21,26 @@ export function checkIfHasUnreadReactions(global: GlobalState, reactions: ApiRea
}
export function areReactionsEmpty(reactions: ApiReactions) {
return !reactions.results.some(({ count }) => count > 0);
return !reactions.results.some(({ count, localAmount }) => count || localAmount);
}
export function getReactionKey(reaction: ApiReaction): ApiReactionKey {
if ('emoticon' in reaction) {
return `emoji-${reaction.emoticon}`;
export function getReactionKey(reaction: ApiReactionWithPaid): ApiReactionKey {
switch (reaction.type) {
case 'emoji':
return `emoji-${reaction.emoticon}`;
case 'custom':
return `document-${reaction.documentId}`;
case 'paid':
return 'paid';
default: {
// Legacy reactions
const uniqueValue = (reaction as any).emoticon || (reaction as any).documentId;
return `unsupported-${uniqueValue}`;
}
}
return `document-${reaction.documentId}`;
}
export function isSameReaction(first?: ApiReaction, second?: ApiReaction) {
export function isSameReaction(first?: ApiReactionWithPaid, second?: ApiReactionWithPaid) {
if (first === second) {
return true;
}
@ -43,9 +52,9 @@ export function isSameReaction(first?: ApiReaction, second?: ApiReaction) {
return getReactionKey(first) === getReactionKey(second);
}
export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatReactions) {
export function canSendReaction(reaction: ApiReactionWithPaid, chatReactions: ApiChatReactions) {
if (chatReactions.type === 'all') {
return 'emoticon' in reaction || chatReactions.areCustomAllowed;
return reaction.type === 'emoji' || chatReactions.areCustomAllowed;
}
if (chatReactions.type === 'some') {
@ -55,13 +64,17 @@ export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatRea
return false;
}
export function sortReactions<T extends ApiAvailableReaction | ApiReaction>(
export function sortReactions<T extends ApiAvailableReaction | ApiReactionWithPaid>(
reactions: T[],
topReactions?: ApiReaction[],
topReactions?: ApiReactionWithPaid[],
): T[] {
return reactions.slice().sort((left, right) => {
const reactionOne = left ? ('reaction' in left ? left.reaction : left) as ApiReaction : undefined;
const reactionTwo = right ? ('reaction' in right ? right.reaction : right) as ApiReaction : undefined;
const reactionOne = left ? ('reaction' in left ? left.reaction : left) as ApiReactionWithPaid : undefined;
const reactionTwo = right ? ('reaction' in right ? right.reaction : right) as ApiReactionWithPaid : undefined;
if (reactionOne?.type === 'paid') return -1;
if (reactionTwo?.type === 'paid') return 1;
const indexOne = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionOne)) || 0;
const indexTwo = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionTwo)) || 0;
return (
@ -73,7 +86,8 @@ export function sortReactions<T extends ApiAvailableReaction | ApiReaction>(
export function getUserReactions(message: ApiMessage): ApiReaction[] {
return message.reactions?.results?.filter((r): r is Required<ApiReactionCount> => isReactionChosen(r))
.sort((a, b) => a.chosenOrder - b.chosenOrder)
.map((r) => r.reaction) || [];
.map((r) => r.reaction)
.filter((r): r is ApiReaction => r.type !== 'paid') || [];
}
export function isReactionChosen(reaction: ApiReactionCount) {
@ -108,3 +122,37 @@ export function updateReactionCount(reactionCount: ApiReactionCount[], newReacti
return results;
}
export function addPaidReaction(
reactionCount: ApiReactionCount[], count: number, isAnonymous?: boolean,
): ApiReactionCount[] {
const results: ApiReactionCount[] = [];
const hasPaid = reactionCount.some((current) => current.reaction.type === 'paid');
if (hasPaid) {
reactionCount.forEach((current) => {
if (current.reaction.type === 'paid') {
results.push({
...current,
localAmount: (current.localAmount || 0) + count,
chosenOrder: -1,
localIsPrivate: isAnonymous !== undefined ? isAnonymous : current.localIsPrivate,
});
return;
}
results.push(current);
});
return results;
}
return [
{
reaction: { type: 'paid' },
count: 0,
chosenOrder: -1,
localAmount: count,
},
...reactionCount,
];
}

View File

@ -57,6 +57,7 @@ import type {
ApiQuickReply,
ApiReaction,
ApiReactionKey,
ApiReactionWithPaid,
ApiReceiptRegular,
ApiReportReason,
ApiSavedReactionTag,
@ -422,7 +423,7 @@ export type TabState = {
};
activeEmojiInteractions?: ActiveEmojiInteraction[];
activeReactions: Record<string, ApiReaction[]>;
activeReactions: Record<string, ApiReactionWithPaid[]>;
middleSearch: {
byChatThreadKey: Record<string, MiddleSearchParams | undefined>;
@ -824,6 +825,11 @@ export type TabState = {
info: ApiCheckedGiftCode;
};
paidReactionModal?: {
chatId: string;
messageId: number;
};
inviteViaLinkModal?: {
missingUsers: ApiMissingInvitedUser[];
chatId: string;
@ -841,6 +847,11 @@ export type TabState = {
starsBalanceModal?: {
originPayment?: TabState['payment'];
originReaction?: {
chatId: string;
messageId: number;
amount: number;
};
};
isStarPaymentModalOpen?: true;
};
@ -1172,6 +1183,7 @@ export type GlobalState = {
privacy: Partial<Record<ApiPrivacyKey, ApiPrivacySettings>>;
notifyExceptions?: Record<number, NotifyException>;
lastPremiumBandwithNotificationDate?: number;
paidReactionPrivacy?: boolean;
};
push?: {
@ -2294,6 +2306,11 @@ export interface ActionPayloads {
};
openStarsBalanceModal: {
originPayment?: TabState['payment'];
originReaction?: {
chatId: string;
messageId: number;
amount: number;
};
} & WithTabId;
closeStarsBalanceModal: WithTabId | undefined;
@ -2389,6 +2406,8 @@ export interface ActionPayloads {
shouldIncludeGrouped?: boolean;
} & WithTabId;
loadPaidReactionPrivacy: undefined;
sendPollVote: {
chatId: string;
messageId: number;
@ -2454,6 +2473,23 @@ export interface ActionPayloads {
shouldAddToRecent?: boolean;
} & WithTabId;
sendPaidReaction: {
chatId: string;
messageId: number;
forcedAmount?: number;
isPrivate?: boolean;
} & WithTabId;
addLocalPaidReaction: {
chatId: string;
messageId: number;
count: number;
isPrivate?: boolean;
} & WithTabId;
resetLocalPaidReactions: {
chatId: string;
messageId: number;
};
setDefaultReaction: {
reaction: ApiReaction;
};
@ -2470,11 +2506,11 @@ export interface ActionPayloads {
startActiveReaction: {
containerId: string;
reaction: ApiReaction;
reaction: ApiReactionWithPaid;
} & WithTabId;
stopActiveReaction: {
containerId: string;
reaction?: ApiReaction;
reaction?: ApiReactionWithPaid;
} & WithTabId;
openEffectPicker: {
@ -3157,15 +3193,7 @@ export interface ActionPayloads {
url?: string;
} & WithTabId;
closeUrlAuthModal: WithTabId | undefined;
showNotification: {
localId?: string;
title?: string;
message: string;
className?: string;
duration?: number;
actionText?: string;
action?: CallbackAction | CallbackAction[];
} & WithTabId;
showNotification: Omit<ApiNotification, 'localId'> & { localId?: string } & WithTabId;
showAllowedMessageTypesNotification: {
chatId: string;
} & WithTabId;
@ -3321,6 +3349,12 @@ export interface ActionPayloads {
openStarsGiftingModal: WithTabId | undefined;
closeStarsGiftingModal: WithTabId | undefined;
openPaidReactionModal: {
chatId: string;
messageId: number;
} & WithTabId;
closePaidReactionModal: WithTabId | undefined;
openDeleteMessageModal: ({
message?: ApiMessage;
isSchedule?: boolean;

View File

@ -24,6 +24,7 @@ const useContextMenuHandlers = (
shouldDisableOnLink?: boolean,
shouldDisableOnLongTap?: boolean,
getIsReady?: Signal<boolean>,
shouldDisablePropagation?: boolean,
) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuAnchor, setContextMenuAnchor] = useState<IAnchorPosition | undefined>(undefined);
@ -133,7 +134,7 @@ const useContextMenuHandlers = (
if (isMenuDisabled) {
return;
}
e.stopPropagation();
if (shouldDisablePropagation) e.stopPropagation();
clearLongPressTimer();
timer = window.setTimeout(() => emulateContextMenuEvent(e), LONG_TAP_DURATION_MS);
@ -154,6 +155,7 @@ const useContextMenuHandlers = (
};
}, [
contextMenuAnchor, isMenuDisabled, shouldDisableOnLongTap, elementRef, shouldDisableOnLink, getIsReady,
shouldDisablePropagation,
]);
return {

View File

@ -1563,6 +1563,8 @@ messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vecto
messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects;
messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector<int> = Vector<FactCheck>;
messages.requestMainWebView#c9e01e7b flags:# compact:flags.7?true peer:InputPeer bot:InputUser start_param:flags.1?string theme_params:flags.0?DataJSON platform:string = WebViewResult;
messages.sendPaidReaction#9dd6a67b flags:# peer:InputPeer msg_id:int count:int random_id:long private:flags.0?Bool = Updates;
messages.getPaidReactionPrivacy#472455aa = Updates;
updates.getState#edd4882a = updates.State;
updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -176,6 +176,7 @@
"messages.sendQuickReplyMessages",
"messages.getFactCheck",
"messages.requestMainWebView",
"messages.getPaidReactionPrivacy",
"updates.getState",
"updates.getDifference",
"updates.getChannelDifference",
@ -310,6 +311,7 @@
"messages.getSavedReactionTags",
"messages.updateSavedReactionTag",
"messages.getDefaultTagReactions",
"messages.sendPaidReaction",
"help.getPremiumPromo",
"channels.deactivateAllUsernames",
"channels.toggleForum",

View File

@ -192,6 +192,7 @@ $color-message-story-mention-to: #74bcff;
--color-deleted-account: #9eaab5;
--color-archive: #9eaab5;
--stars-gradient: linear-gradient(90deg, #FFAA00 0%, #FFCD3A 100%);
--color-heart: #ff3c32;

View File

@ -33,6 +33,7 @@ body {
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--font-family-monospace: "Cascadia Mono", "Roboto Mono", "Droid Sans Mono", 'SF Mono', "Menlo", "Ubuntu Mono",
"Consolas", monospace;
--font-family-rounded: -ui-rounded, "Roboto Round";
@media (max-width: 600px) {
height: calc(var(--vh, 1vh) * 100);

View File

@ -13,6 +13,7 @@ import type {
ApiMessage,
ApiPhoto,
ApiReaction,
ApiReactionWithPaid,
ApiStickerSet,
ApiUser,
ApiVideo,
@ -274,7 +275,7 @@ export enum SettingsScreens {
export type StickerSetOrReactionsSetOrRecent = Pick<ApiStickerSet, (
'id' | 'accessHash' | 'title' | 'count' | 'stickers' | 'isEmoji' | 'installedDate' | 'isArchived' |
'hasThumbnail' | 'hasStaticThumb' | 'hasAnimatedThumb' | 'hasVideoThumb' | 'thumbCustomEmojiId'
)> & { reactions?: ApiReaction[] };
)> & { reactions?: ApiReactionWithPaid[] };
export enum LeftColumnContent {
ChatList,

View File

@ -1513,7 +1513,7 @@ export interface LangPair {
'RemoveEffect': undefined;
'ReplyInPrivateMessage': undefined;
'ProfileOpenAppAbout': {
'terms': string;
'terms': string | number;
};
'ProfileOpenAppTerms': undefined;
'ProfileBotOpenAppInfoLink': undefined;
@ -1539,12 +1539,21 @@ export interface LangPair {
'GiftStarsOutgoing': {
'user': string | number;
};
'SendPaidReaction': {
'amount': string | number;
};
'StarsReactionTerms': {
'link': string | number;
};
'StarsReactionLinkText': undefined;
'StarsReactionLink': undefined;
'MiniAppsMoreTabs': {
'botName': string | number;
};
'PrizeCredits': {
'count': string | number;
};
}
export type LangKey = keyof LangPair;

View File

@ -1,4 +1,4 @@
const SITE_FONTS = ['400 1em Roboto', '500 1em Roboto'];
const SITE_FONTS = ['400 1em Roboto', '500 1em Roboto', "500 1em 'Roboto Round'"];
export default function preloadFonts() {
if ('fonts' in document) {

View File

@ -401,10 +401,11 @@ async function getAvatar(chat: ApiPeer) {
function getReactionEmoji(reaction: ApiPeerReaction) {
let emoji;
if ('emoticon' in reaction.reaction) {
if (reaction.reaction.type === 'emoji') {
emoji = reaction.reaction.emoticon;
}
if ('documentId' in reaction.reaction) {
if (reaction.reaction.type === 'custom') {
// eslint-disable-next-line eslint-multitab-tt/no-immediate-global
emoji = getGlobal().customEmojis.byId[reaction.reaction.documentId]?.emoji;
}
@ -470,7 +471,7 @@ export async function notifyAboutMessage({
if (isReaction && !activeReaction) return;
// If this is a custom emoji reaction we need to make sure it is loaded
if (isReaction && activeReaction && 'documentId' in activeReaction.reaction) {
if (isReaction && activeReaction && activeReaction.reaction.type === 'custom') {
await loadCustomEmoji(activeReaction.reaction.documentId);
}

View File

@ -11,7 +11,7 @@ export function formatInteger(value: number) {
function formatFixedNumber(number: number) {
const fixed = String(number.toFixed(1));
if (fixed.substr(-2) === '.0') {
return Math.round(number);
return Math.floor(number);
}
return number.toFixed(1).replace('.', ',');