From 8a2f067c9bc4c1a7a71a5420c5d8a871ea420a5c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sat, 1 Mar 2025 17:58:56 +0100 Subject: [PATCH] Paid Reactions: Support peers (#5581) --- src/api/gramjs/apiBuilders/reactions.ts | 18 +- src/api/gramjs/gramjsBuilders/index.ts | 13 ++ src/api/gramjs/methods/messages.ts | 7 +- src/api/gramjs/methods/reactions.ts | 8 +- src/api/gramjs/updates/mtpUpdateHandler.ts | 3 +- src/api/types/chats.ts | 2 + src/api/types/messages.ts | 19 +- src/api/types/updates.ts | 3 +- src/assets/localization/fallback.strings | 2 + .../PaidReactionModal.module.scss | 70 +++++- .../modals/paidReaction/PaidReactionModal.tsx | 216 +++++++++++++++--- src/components/ui/DropdownMenu.tsx | 3 + src/global/actions/api/messages.ts | 25 +- src/global/actions/api/reactions.ts | 10 +- src/global/actions/apiUpdaters/messages.ts | 3 +- src/global/actions/apiUpdaters/misc.ts | 2 +- src/global/helpers/reactions.ts | 4 +- src/global/types/actions.ts | 5 + src/global/types/globalState.ts | 3 +- src/lib/gramjs/tl/AllTLObjects.ts | 2 +- src/lib/gramjs/tl/api.d.ts | 42 +++- src/lib/gramjs/tl/apiTl.ts | 13 +- src/lib/gramjs/tl/static/api.tl | 17 +- src/types/language.d.ts | 2 + 24 files changed, 417 insertions(+), 75 deletions(-) diff --git a/src/api/gramjs/apiBuilders/reactions.ts b/src/api/gramjs/apiBuilders/reactions.ts index f3f35a73f..169ea12b7 100644 --- a/src/api/gramjs/apiBuilders/reactions.ts +++ b/src/api/gramjs/apiBuilders/reactions.ts @@ -4,6 +4,7 @@ import type { ApiAvailableEffect, ApiAvailableReaction, ApiMessageReactor, + ApiPaidReactionPrivacyType, ApiPeerReaction, ApiReaction, ApiReactionCount, @@ -66,7 +67,7 @@ export function buildApiMessageReactor(reactor: GramJs.MessageReactor): ApiMessa return { peerId: peerId && getApiChatIdFromMtpPeer(peerId), count, - isMe: my, + isMy: my, isTop: top, isAnonymous: anonymous, }; @@ -164,3 +165,18 @@ export function buildApiAvailableEffect(availableEffect: GramJs.AvailableEffect) effectAnimationId: effectAnimationId?.toString(), }; } + +export function buildApiPaidReactionPrivacy(privacy: GramJs.TypePaidReactionPrivacy) : ApiPaidReactionPrivacyType { + if (privacy instanceof GramJs.PaidReactionPrivacyAnonymous) { + return { type: 'anonymous' }; + } + + if (privacy instanceof GramJs.PaidReactionPrivacyPeer) { + return { + type: 'peer', + peerId: getApiChatIdFromMtpPeer(privacy.peer), + }; + } + + return { type: 'default' }; +} diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 3c14cfcea..49ffe66a0 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -93,6 +93,19 @@ export function buildInputPeer(chatOrUserId: string, accessHash?: string): GramJ } } +export function buildInputPaidReactionPrivacy(isPrivate?: boolean, peerId?: string): GramJs.TypeInputPeer { + if (isPrivate) return new GramJs.PaidReactionPrivacyAnonymous(); + if (peerId) { + const peer = buildInputPeerFromLocalDb(peerId); + if (peer) { + return new GramJs.PaidReactionPrivacyPeer({ + peer, + }); + } + } + return new GramJs.PaidReactionPrivacyDefault(); +} + export function buildInputPeerFromLocalDb(chatOrUserId: string): GramJs.TypeInputPeer | undefined { const type = getEntityTypeById(chatOrUserId); let accessHash: BigInt.BigInteger | undefined; diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 4781deee8..af7b97d91 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1719,10 +1719,13 @@ export async function fetchSeenBy({ chat, messageId }: { chat: ApiChat; messageI export async function fetchSendAs({ chat, + isForPaidReactions, }: { + isForPaidReactions?: true; chat: ApiChat; }) { const result = await invokeRequest(new GramJs.channels.GetSendAs({ + forPaidReactions: isForPaidReactions, peer: buildInputPeer(chat.id, chat.accessHash), }), { shouldIgnoreErrors: true, @@ -1733,9 +1736,7 @@ export async function fetchSendAs({ return undefined; } - return { - sendAs: result.peers.map(buildApiSendAsPeerId), - }; + return result.peers.map(buildApiSendAsPeerId); } export function saveDefaultSendAs({ diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index 72836f3ab..16d50875a 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -20,7 +20,9 @@ import { buildMessagePeerReaction, } from '../apiBuilders/reactions'; import { buildStickerFromDocument } from '../apiBuilders/symbols'; -import { buildInputPeer, buildInputReaction, generateRandomTimestampedBigInt } from '../gramjsBuilders'; +import { + buildInputPaidReactionPrivacy, buildInputPeer, buildInputReaction, generateRandomTimestampedBigInt, +} from '../gramjsBuilders'; import localDb from '../localDb'; import { invokeRequest } from './client'; @@ -155,18 +157,20 @@ export function sendPaidReaction({ messageId, count, isPrivate, + peerId, }: { chat: ApiChat; messageId: number; count: number; isPrivate?: boolean; + peerId?: string; }) { return invokeRequest(new GramJs.messages.SendPaidReaction({ peer: buildInputPeer(chat.id, chat.accessHash), msgId: messageId, randomId: generateRandomTimestampedBigInt(), count, - private: isPrivate || undefined, + private: buildInputPaidReactionPrivacy(isPrivate, peerId), }), { shouldReturnTrue: true, shouldThrow: true, diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 2dd8f0948..cb87fb6f6 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -55,6 +55,7 @@ import { import { buildApiStarsAmount } from '../apiBuilders/payments'; import { buildApiEmojiStatus, buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { + buildApiPaidReactionPrivacy, buildApiReaction, buildMessageReactions, } from '../apiBuilders/reactions'; @@ -1071,7 +1072,7 @@ export function updater(update: Update) { } else if (update instanceof GramJs.UpdatePaidReactionPrivacy) { sendApiUpdate({ '@type': 'updatePaidReactionPrivacy', - isPrivate: update.private, + private: buildApiPaidReactionPrivacy(update.private), }); } else if (update instanceof GramJs.UpdateLangPackTooLong) { sendApiUpdate({ diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 2139e3392..7165de167 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -77,6 +77,8 @@ export interface ApiChat { isJoinToSend?: boolean; isJoinRequest?: boolean; sendAsPeerIds?: ApiSendAsPeerId[]; + sendPaidReactionsAsPeerIds?: ApiSendAsPeerId[]; + sendPaidReactionsPeer?: ApiSendAsPeerId; unreadReactions?: number[]; unreadMentions?: number[]; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 79cfd96a0..8e89c654d 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -675,7 +675,7 @@ export interface ApiPeerReaction { export interface ApiMessageReactor { isTop?: true; - isMe?: true; + isMy?: true; count: number; isAnonymous?: true; peerId?: string; @@ -687,6 +687,7 @@ export interface ApiReactionCount { reaction: ApiReactionWithPaid; localAmount?: number; localIsPrivate?: boolean; + localPeerId?: string; localPreviousChosenOrder?: number; } @@ -750,6 +751,22 @@ export type ApiSavedReactionTag = { count: number; }; +export type ApiPaidReactionPrivacyType = ApiPaidReactionPrivacyDefault | +ApiPaidReactionPrivacyAnonymous | PaidReactionPrivacyPeer; + +export type ApiPaidReactionPrivacyDefault = { + type: 'default'; +}; + +export type ApiPaidReactionPrivacyAnonymous = { + type: 'anonymous'; +}; + +export type PaidReactionPrivacyPeer = { + type: 'peer'; + peerId: string; +}; + interface ApiBaseThreadInfo { chatId: string; messagesCount: number; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 982d04f3a..f3778bd41 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -24,6 +24,7 @@ import type { ApiFormattedText, ApiMediaExtendedPreview, ApiMessage, + ApiPaidReactionPrivacyType, ApiPhoto, ApiPoll, ApiQuickReply, @@ -789,7 +790,7 @@ export type ApiUpdateEntities = { export type ApiUpdatePaidReactionPrivacy = { '@type': 'updatePaidReactionPrivacy'; - isPrivate: boolean; + private: ApiPaidReactionPrivacyType; }; export type ApiUpdateLangPackTooLong = { diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index ba9d66452..fce5bab4f 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1633,3 +1633,5 @@ "UniqueStatusProofOfOwnershipDescription" = "Tapping the icon of this item next to your name will show its info and owner."; "UniqueStatusWearButton" = "Start Wearing"; "CollectibleStatusesCategory" = "Collectibles"; +"PeerPersonalAccount" = "personal account"; +"PeerChannel" = "channel"; diff --git a/src/components/modals/paidReaction/PaidReactionModal.module.scss b/src/components/modals/paidReaction/PaidReactionModal.module.scss index 91cf189b4..a4578c6d6 100644 --- a/src/components/modals/paidReaction/PaidReactionModal.module.scss +++ b/src/components/modals/paidReaction/PaidReactionModal.module.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; gap: 0.25rem; + max-height: min(92vh, 38rem) !important; } .title { @@ -11,7 +12,7 @@ } .slider { - margin-top: 1.5rem; + margin-top: 2rem; flex-shrink: 0; } @@ -23,13 +24,75 @@ text-align: center; } -.modalBalance { +.itemInfo { + flex-grow: 1; + overflow: hidden; + height: auto; + display: flex; + flex-direction: column; + margin-left: 0.5rem; +} + +.itemTitle { + line-height: 1rem; + margin-bottom: 0.125rem; +} + +.itemSubtitle { + font-size: 0.9375rem; + color: var(--color-text-secondary); + line-height: 1rem; +} + +.itemIcon { + margin-left: 0.5rem; + margin-right: 0 !important; +} + +.sendAsPeerMenuButton { + margin-right: 0.25rem; + border-radius: 1.5rem; + padding: 0.25rem !important; + width: auto; + height: auto !important; + background-color: var(--color-background-secondary) !important; + + :active, + :hover { + background-color: var(--color-interactive-element-hover) !important; + } +} + +.buttonDownIcon { + margin-left: 0.25rem; + margin-right: 0.125rem; +} + +.sendAsPeerMenuBubble { + max-width: 16rem; +} + +.sendAsPeerMenu { + margin-right: 0.25rem; +} + +.headerControlPanel { + display: flex; + align-items: center; position: absolute; top: 0.75rem; right: 1.25rem; z-index: 3; } +.separator { + margin-top: 1rem; +} + +.checkBox { + margin-block: 0.25rem !important; +} + .topLabel { background-image: var(--stars-gradient); color: var(--color-white); @@ -61,5 +124,6 @@ font-size: 0.875rem; align-self: center; color: var(--color-text-secondary); - margin-bottom: 0; + margin-top: 0.5rem; + margin-bottom: 0.125rem; } diff --git a/src/components/modals/paidReaction/PaidReactionModal.tsx b/src/components/modals/paidReaction/PaidReactionModal.tsx index 77a1d5989..b6aca8fb7 100644 --- a/src/components/modals/paidReaction/PaidReactionModal.tsx +++ b/src/components/modals/paidReaction/PaidReactionModal.tsx @@ -1,30 +1,43 @@ +import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect, useMemo, useState, } from '../../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../../global'; import type { - ApiChat, ApiMessage, ApiStarsAmount, ApiUser, + ApiChat, ApiMessage, ApiPaidReactionPrivacyType, + ApiPeer, + ApiSendAsPeerId, + ApiStarsAmount, ApiUser, } from '../../../api/types'; import type { TabState } from '../../../global/types'; import type { CustomPeer } from '../../../types'; import { STARS_ICON_PLACEHOLDER } from '../../../config'; -import { getChatTitle, getUserFullName } from '../../../global/helpers'; -import { selectChat, selectChatMessage, selectUser } from '../../../global/selectors'; +import { getPeerTitle } from '../../../global/helpers'; +import { isApiPeerUser } from '../../../global/helpers/peers'; +import { + selectChat, selectChatMessage, selectPeer, selectUser, +} from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; import { formatInteger } from '../../../util/textFormat'; import renderText from '../../common/helpers/renderText'; +import useAppLayout from '../../../hooks/useAppLayout'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; +import Avatar from '../../common/Avatar'; +import FullNameTitle from '../../common/FullNameTitle'; 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 DropdownMenu from '../../ui/DropdownMenu'; +import MenuItem from '../../ui/MenuItem'; import Modal from '../../ui/Modal'; import Separator from '../../ui/Separator'; import BalanceBlock from '../stars/BalanceBlock'; @@ -41,15 +54,18 @@ type StateProps = { chat?: ApiChat; maxAmount: number; starBalance?: ApiStarsAmount; - defaultPrivacy?: boolean; + defaultPrivacy?: ApiPaidReactionPrivacyType; + sendPaidReactionsAsPeerIds?: ApiSendAsPeerId[]; + currentUserId: string; + currentUser: ApiUser; }; type ReactorData = { amount: number; localAmount: number; - isMe?: boolean; + isMy?: boolean; isAnonymous?: boolean; - user?: ApiUser; + user?: ApiPeer; }; const MAX_TOP_REACTORS = 3; @@ -69,18 +85,27 @@ const PaidReactionModal = ({ maxAmount, starBalance, defaultPrivacy, + sendPaidReactionsAsPeerIds, + currentUserId, + currentUser, }: OwnProps & StateProps) => { - const { closePaidReactionModal, addLocalPaidReaction } = getActions(); + const { closePaidReactionModal, addLocalPaidReaction, loadSendPaidReactionsAs } = getActions(); const [starsAmount, setStarsAmount] = useState(DEFAULT_STARS_AMOUNT); const [isTouched, markTouched, unmarkTouched] = useFlag(); - const [shouldShowUp, setShouldShowUp] = useState(true); + const [shouldSendAsAnonymous, setShouldSendAsAnonymous] = useState(true); + const [sendAsPeerId, setSendAsPeerId] = useState(currentUserId); + + const chatId = chat?.id; + + const senderPeer = sendAsPeerId ? (selectPeer(getGlobal(), sendAsPeerId)) : currentUser; const oldLang = useOldLang(); + const { isMobile } = useAppLayout(); const lang = useLang(); - const handleAnonimityChange = useLastCallback((e: React.ChangeEvent) => { - setShouldShowUp(e.target.checked); + const handleShowInTopSendersChange = useLastCallback((e: React.ChangeEvent) => { + setShouldSendAsAnonymous(!e.target.checked); }); const handleAmountChange = useLastCallback((value: number) => { @@ -88,6 +113,21 @@ const PaidReactionModal = ({ markTouched(); }); + useEffect(() => { + if (chatId && !sendPaidReactionsAsPeerIds) { + loadSendPaidReactionsAs({ chatId }); + } + }, [chatId, sendPaidReactionsAsPeerIds]); + + const filteredMyReactorIds = useMemo(() => { + const result = sendPaidReactionsAsPeerIds?.map((peer) => peer.id) + .filter((id) => id !== chatId); + result?.unshift(currentUserId); + return result; + }, [sendPaidReactionsAsPeerIds, chatId, currentUserId]); + + const canChangeSendAsPeer = filteredMyReactorIds && filteredMyReactorIds.length > 1; + useEffect(() => { if (!modal) { unmarkTouched(); @@ -95,14 +135,23 @@ const PaidReactionModal = ({ }, [modal]); useEffect(() => { - const currentReactor = message?.reactions?.topReactors?.find((reactor) => reactor.isMe); + const currentReactor = message?.reactions?.topReactors?.find((reactor) => reactor.isMy); if (currentReactor) { - setShouldShowUp(!currentReactor.isAnonymous); + setShouldSendAsAnonymous(Boolean(currentReactor.isAnonymous)); + if (currentReactor.peerId) { + setSendAsPeerId(currentReactor.peerId); + } return; } - setShouldShowUp(defaultPrivacy || true); - }, [defaultPrivacy, message?.reactions?.topReactors]); + setShouldSendAsAnonymous(defaultPrivacy?.type === 'anonymous' || false); + if (defaultPrivacy?.type === 'peer' && filteredMyReactorIds?.includes(defaultPrivacy.peerId)) { + setSendAsPeerId(defaultPrivacy.peerId); + return; + } + + setSendAsPeerId(currentUserId); + }, [defaultPrivacy, message?.reactions?.topReactors, filteredMyReactorIds, currentUserId]); const handleSend = useLastCallback(() => { if (!modal) return; @@ -111,11 +160,85 @@ const PaidReactionModal = ({ chatId: modal.chatId, messageId: modal.messageId, count: starsAmount, - isPrivate: !shouldShowUp, + isPrivate: shouldSendAsAnonymous, + peerId: shouldSendAsAnonymous || sendAsPeerId === currentUserId ? undefined : sendAsPeerId, + shouldIgnoreDefaultPrivacy: true, }); closePaidReactionModal(); }); + const handleSendAsPeerChange = useLastCallback((peerId: string) => { + setShouldSendAsAnonymous(false); + setSendAsPeerId(peerId); + }); + + const renderMenuItem = useLastCallback((peerId: string) => { + const peer = selectPeer(getGlobal(), peerId); + const isSelected = sendAsPeerId === peerId && !shouldSendAsAnonymous; + if (!peer) return undefined; + + return ( + handleSendAsPeerChange(peerId)} + > + +
+ + + {isApiPeerUser(peer) ? lang('PeerPersonalAccount') : lang('PeerChannel')} + +
+ +
+ ); + }); + + const SendAsPeerMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => ( + + ); + }, [isMobile, lang, senderPeer, shouldSendAsAnonymous]); + + const sendAsPeersMenu = useMemo(() => { + if (!canChangeSendAsPeer) return undefined; + return ( + + {filteredMyReactorIds.map((id) => ( + renderMenuItem(id) + ))} + + ); + }, [SendAsPeerMenuButton, filteredMyReactorIds, canChangeSendAsPeer]); + const topReactors = useMemo(() => { const global = getGlobal(); const all = message?.reactions?.topReactors; @@ -124,41 +247,49 @@ const PaidReactionModal = ({ } const result: ReactorData[] = []; - let hasMe = false; + let hasCurrentSender = false; + let myReactorAmount = 0; all.forEach((reactor) => { - const user = reactor.peerId ? selectUser(global, reactor.peerId) : undefined; - if (!user && !reactor.isAnonymous && !reactor.isMe) return; + const peer = reactor.peerId ? selectPeer(global, reactor.peerId) : undefined; + if (!peer && !reactor.isAnonymous && !reactor.isMy) return; + if (reactor.isMy) { + myReactorAmount = reactor.count; + } - if (reactor.isMe) { - hasMe = true; + if (reactor.isMy && (reactor.peerId !== sendAsPeerId || (reactor.isAnonymous && !shouldSendAsAnonymous))) return; + + const isCurrentReactor = sendAsPeerId === reactor.peerId || (shouldSendAsAnonymous && reactor.isAnonymous); + + if (isCurrentReactor) { + hasCurrentSender = true; } result.push({ amount: reactor.count, - localAmount: reactor.isMe && isTouched ? starsAmount : 0, - isMe: reactor.isMe, + localAmount: isCurrentReactor && isTouched ? starsAmount : 0, + isMy: reactor.isMy, isAnonymous: reactor.isAnonymous, - user, + user: peer, }); }); - if (!hasMe && isTouched) { - const me = selectUser(global, global.currentUserId!); + if (!hasCurrentSender) { + const sender = selectPeer(global, sendAsPeerId); result.push({ - amount: 0, - localAmount: starsAmount, - isMe: true, - user: me, + amount: myReactorAmount, + localAmount: isTouched ? starsAmount : 0, + isMy: true, + user: sender, }); } result.sort((a, b) => (b.amount + b.localAmount) - (a.amount + a.localAmount)); return result.slice(0, MAX_TOP_REACTORS); - }, [isTouched, message?.reactions?.topReactors, starsAmount]); + }, [isTouched, message?.reactions?.topReactors, starsAmount, sendAsPeerId, shouldSendAsAnonymous]); - const chatTitle = chat && getChatTitle(oldLang, chat); + const chatTitle = chat && getPeerTitle(oldLang, chat); return ( - +
+ {sendAsPeersMenu} + +
{topReactors.map((reactor) => { const countText = formatInteger(reactor.amount + reactor.localAmount); - const peer = (reactor.isAnonymous || !reactor.user || (reactor.isMe && !shouldShowUp)) + const peer = (reactor.isAnonymous || !reactor.user || (reactor.isMy && shouldSendAsAnonymous)) ? ANONYMOUS_PEER : reactor.user; - const text = 'isCustomPeer' in peer ? oldLang(peer.titleKey) : getUserFullName(peer); + const text = 'isCustomPeer' in peer ? oldLang(peer.titleKey) + : peer && getPeerTitle(oldLang, peer); return ( )} + {topReactors && () }