Profile: Show collectible info (#4505)

This commit is contained in:
Alexander Zinchuk 2024-05-03 14:38:23 +02:00
parent 37ad59d937
commit 1f834d42ed
24 changed files with 382 additions and 27 deletions

View File

@ -336,7 +336,7 @@ function buildAction(
let currency: string | undefined;
let giftCryptoInfo: {
currency: string;
amount: string;
amount: number;
} | undefined;
let text: string;
const translationValues: string[] = [];
@ -499,10 +499,9 @@ function buildAction(
}
currency = action.currency;
if (action.cryptoCurrency) {
const cryptoAmountWithDecimals = action.cryptoAmount!.divide(1e7).toJSNumber() / 100;
giftCryptoInfo = {
currency: action.cryptoCurrency,
amount: cryptoAmountWithDecimals.toFixed(2),
amount: action.cryptoAmount!.toJSNumber(),
};
}
amount = action.amount.toJSNumber();
@ -552,10 +551,9 @@ function buildAction(
}
currency = action.currency;
if (action.cryptoCurrency) {
const cryptoAmountWithDecimals = action.cryptoAmount!.divide(1e7).toJSNumber() / 100;
giftCryptoInfo = {
currency: action.cryptoCurrency,
amount: cryptoAmountWithDecimals.toFixed(2),
amount: action.cryptoAmount!.toJSNumber(),
};
}
if (action.boostPeer) {

View File

@ -3,6 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { ApiPrivacyKey } from '../../../types';
import type {
ApiChatLink,
ApiCollectionInfo,
ApiConfig, ApiCountry, ApiLangString,
ApiPeerColors,
ApiSession, ApiTimezone, ApiUrlAuthResult, ApiWallpaper, ApiWebSession,
@ -274,3 +275,23 @@ export function buildApiChatLink(data: GramJs.account.ResolvedBusinessChatLinks)
text: buildMessageTextContent(data.message, data.entities),
};
}
export function buildApiCollectibleInfo(info: GramJs.fragment.TypeCollectibleInfo): ApiCollectionInfo {
const {
amount,
currency,
cryptoAmount,
cryptoCurrency,
purchaseDate,
url,
} = info;
return {
amount: amount.toJSNumber(),
currency,
cryptoAmount: cryptoAmount.toJSNumber(),
cryptoCurrency,
purchaseDate,
url,
};
}

View File

@ -0,0 +1,26 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { buildApiCollectibleInfo } from '../apiBuilders/misc';
import { invokeRequest } from './client';
type InputCollectible = {
phone: string;
} | {
username: string;
};
export async function fetchCollectionInfo(collectible: InputCollectible) {
const inputCollectible = 'username' in collectible
? new GramJs.InputCollectibleUsername({ username: collectible.username })
: new GramJs.InputCollectiblePhone({ phone: collectible.phone });
const result = await invokeRequest(new GramJs.fragment.GetCollectibleInfo({
collectible: inputCollectible,
}));
if (!result) {
return undefined;
}
return buildApiCollectibleInfo(result);
}

View File

@ -102,3 +102,5 @@ export {
applyBoost, fetchBoostList, fetchBoostStatus, fetchGiveawayInfo, fetchMyBoosts, applyGiftCode, checkGiftCode,
getPremiumGiftCodeOptions, launchPrepaidGiveaway,
} from './payments';
export * from './fragment';

View File

@ -351,7 +351,7 @@ export interface ApiAction {
currency?: string;
giftCryptoInfo?: {
currency: string;
amount: string;
amount: number;
};
translationValues: string[];
call?: Partial<ApiGroupCall>;

View File

@ -267,3 +267,12 @@ type ApiUrlAuthResultDefault = {
};
export type ApiUrlAuthResult = ApiUrlAuthResultRequest | ApiUrlAuthResultAccepted | ApiUrlAuthResultDefault;
export interface ApiCollectionInfo {
amount: number;
currency: string;
cryptoAmount: number;
cryptoCurrency: string;
purchaseDate: number;
url: string;
}

Binary file not shown.

Binary file not shown.

View File

@ -39,6 +39,7 @@ export { default as CountryPickerModal } from '../components/common/CountryPicke
export { default as ReactorListModal } from '../components/middle/ReactorListModal';
export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation';
export { default as ChatLanguageModal } from '../components/middle/ChatLanguageModal';
export { default as CollectibleInfoModal } from '../components/modals/collectible/CollectibleInfoModal';
export { default as LeftSearch } from '../components/left/search/LeftSearch';
export { default as Settings } from '../components/left/settings/Settings';

View File

@ -8,6 +8,8 @@ import VoiceMini from '../../../assets/tgs/calls/VoiceMini.tgs';
import VoiceMuted from '../../../assets/tgs/calls/VoiceMuted.tgs';
import VoiceOutlined from '../../../assets/tgs/calls/VoiceOutlined.tgs';
import Flame from '../../../assets/tgs/general/Flame.tgs';
import Fragment from '../../../assets/tgs/general/Fragment.tgs';
import Mention from '../../../assets/tgs/general/Mention.tgs';
import PartyPopper from '../../../assets/tgs/general/PartyPopper.tgs';
import Invite from '../../../assets/tgs/invites/Invite.tgs';
import JoinRequest from '../../../assets/tgs/invites/Requests.tgs';
@ -54,4 +56,6 @@ export const LOCAL_TGS_URLS = {
ReadTime,
Unlock,
LastSeen,
Mention,
Fragment,
};

View File

@ -0,0 +1,5 @@
import { TME_LINK_PREFIX } from '../../../config';
export default function formatUsername(username: string, asAbsoluteLink?: boolean) {
return asAbsoluteLink ? `${TME_LINK_PREFIX}${username}` : `@${username}`;
}

View File

@ -138,7 +138,8 @@ export function renderActionMessageText(
let priceText = price;
if (giftCryptoInfo) {
priceText = `${giftCryptoInfo.amount} ${giftCryptoInfo.currency} (~${price})`;
const cryptoPrice = formatCurrency(giftCryptoInfo.amount, giftCryptoInfo.currency, lang.code);
priceText = `${cryptoPrice} (${price})`;
}
processed = processPlaceholder(

View File

@ -9,7 +9,7 @@ import type {
} from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { TME_LINK_PREFIX } from '../../../config';
import { FRAGMENT_PHONE_CODE, FRAGMENT_PHONE_LENGTH } from '../../../config';
import {
buildStaticMapHash,
getChatLink,
@ -33,6 +33,7 @@ import { formatPhoneNumberWithCode } from '../../../util/phoneNumber';
import { debounce } from '../../../util/schedulers';
import stopEvent from '../../../util/stopEvent';
import { ChatAnimationTypes } from '../../left/main/hooks';
import formatUsername from '../helpers/formatUsername';
import renderText from '../helpers/renderText';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
@ -102,6 +103,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
loadPeerStories,
openSavedDialog,
openMapModal,
requestCollectibleInfo,
} = getActions();
const {
@ -206,10 +208,6 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
openSavedDialog({ chatId: chatOrUserId });
});
if (!chat || chat.isRestricted || (isSelf && !isInSettings)) {
return undefined;
}
function copy(text: string, entity: string) {
copyTextToClipboard(text);
showNotification({ message: `${entity} was copied` });
@ -217,6 +215,26 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
const formattedNumber = phoneNumber && formatPhoneNumberWithCode(phoneCodeList, phoneNumber);
const handlePhoneClick = useLastCallback(() => {
if (phoneNumber?.length === FRAGMENT_PHONE_LENGTH && phoneNumber.startsWith(FRAGMENT_PHONE_CODE)) {
requestCollectibleInfo({ collectible: phoneNumber, userId: userId!, type: 'phone' });
return;
}
copy(formattedNumber!, lang('Phone'));
});
const handleUsernameClick = useLastCallback((username: ApiUsername, isChat?: boolean) => {
if (!username.isEditable) {
requestCollectibleInfo({ collectible: username.username, userId: userId!, type: 'username' });
return;
}
copy(formatUsername(username.username, isChat), lang(isChat ? 'Link' : 'Username'));
});
if (!chat || chat.isRestricted || (isSelf && !isInSettings)) {
return undefined;
}
function renderUsernames(usernameList: ApiUsername[], isChat?: boolean) {
const [mainUsername, ...otherUsernames] = usernameList;
@ -226,21 +244,21 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
.map((s) => {
return (s === 'USERNAMES' ? (
<>
{otherUsernames.map(({ username: nick }, idx) => {
const textToCopy = isChat ? `${TME_LINK_PREFIX}${nick}` : `@${nick}`;
{otherUsernames.map((username, idx) => {
return (
<>
{idx > 0 ? ', ' : ''}
<a
key={nick}
href={`${TME_LINK_PREFIX}${nick}`}
key={username.username}
href={formatUsername(username.username, true)}
onMouseDown={stopEvent}
onClick={(e) => {
stopEvent(e);
copy(textToCopy, lang(isChat ? 'Link' : 'Username'));
handleUsernameClick(username, isChat);
}}
className="text-entity-link username-link"
>
{`@${nick}`}
{formatUsername(username.username)}
</a>
</>
);
@ -250,9 +268,6 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
})
: undefined;
const username = isChat ? `t.me/${mainUsername.username}` : mainUsername.username;
const textToCopy = isChat ? `${TME_LINK_PREFIX}${mainUsername.username}` : `@${mainUsername.username}`;
return (
<ListItem
icon={isChat ? 'link' : 'mention'}
@ -260,9 +275,11 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
narrow
ripple
// eslint-disable-next-line react/jsx-no-bind
onClick={() => copy(textToCopy, lang(isChat ? 'Link' : 'Username'))}
onClick={() => {
handleUsernameClick(mainUsername, isChat);
}}
>
<span className="title" dir="auto">{username}</span>
<span className="title" dir="auto">{formatUsername(mainUsername.username, isChat)}</span>
<span className="subtitle">
{usernameLinks && <span className="other-usernames">{usernameLinks}</span>}
{lang(isChat ? 'Link' : 'Username')}
@ -289,9 +306,9 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
/>
</div>
)}
{formattedNumber && Boolean(formattedNumber.length) && (
{Boolean(formattedNumber?.length) && (
// eslint-disable-next-line react/jsx-no-bind
<ListItem icon="phone" multiline narrow ripple onClick={() => copy(formattedNumber, lang('Phone'))}>
<ListItem icon="phone" multiline narrow ripple onClick={handlePhoneClick}>
<span className="title" dir="auto">{formattedNumber}</span>
<span className="subtitle">{lang('Phone')}</span>
</ListItem>

View File

@ -9,6 +9,7 @@ import { pick } from '../../util/iteratees';
import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async';
import BoostModal from './boost/BoostModal.async';
import ChatlistModal from './chatlist/ChatlistModal.async';
import CollectibleInfoModal from './collectible/CollectibleInfoModal.async';
import GiftCodeModal from './giftcode/GiftCodeModal.async';
import InviteViaLinkModal from './inviteViaLink/InviteViaLinkModal.async';
import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async';
@ -25,6 +26,7 @@ type ModalKey = keyof Pick<TabState,
'oneTimeMediaModal' |
'inviteViaLinkModal' |
'requestedAttachBotInstall' |
'collectibleInfoModal' |
'reportAdModal' |
'webApp'
>;
@ -51,6 +53,7 @@ const MODALS: ModalRegistry = {
requestedAttachBotInstall: AttachBotInstallModal,
reportAdModal: ReportAdModal,
webApp: WebAppModal,
collectibleInfoModal: CollectibleInfoModal,
};
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 './CollectibleInfoModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const CollectibleInfoModalAsync: FC<OwnProps> = (props) => {
const { modal } = props;
const CollectibleInfoModal = useModuleLoader(Bundles.Extra, 'CollectibleInfoModal', !modal);
// eslint-disable-next-line react/jsx-props-no-spreading
return CollectibleInfoModal ? <CollectibleInfoModal {...props} /> : undefined;
};
export default CollectibleInfoModalAsync;

View File

@ -0,0 +1,33 @@
.content {
display: flex;
flex-direction: column;
align-items: center;
}
.closeButton {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
.icon {
width: 5rem;
height: 5rem;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
background-color: var(--color-primary);
}
.title, .description {
text-align: center !important;
text-wrap: pretty;
padding: 0 1rem;
}
.title {
margin-top: 1rem;
}

View File

@ -0,0 +1,156 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo,
useMemo,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiCountryCode } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { copyTextToClipboard } from '../../../util/clipboard';
import { formatDateAtTime } from '../../../util/date/dateFormat';
import { formatCurrency } from '../../../util/formatCurrency';
import { formatPhoneNumberWithCode } from '../../../util/phoneNumber';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import formatUsername from '../../common/helpers/formatUsername';
import renderText from '../../common/helpers/renderText';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import Icon from '../../common/Icon';
import PickerSelectedItem from '../../common/PickerSelectedItem';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import styles from './CollectibleInfoModal.module.scss';
export type OwnProps = {
modal: TabState['collectibleInfoModal'];
};
type StateProps = {
phoneCodeList: ApiCountryCode[];
};
const TOP_ICON_SIZE = 60;
const CollectibleInfoModal: FC<OwnProps & StateProps> = ({
modal,
phoneCodeList,
}) => {
const {
closeCollectibleInfoModal,
openChat,
openUrl,
showNotification,
} = getActions();
const lang = useLang();
const isUsername = modal?.type === 'username';
const handleClose = useLastCallback(() => {
closeCollectibleInfoModal();
});
const handleOpenChat = useLastCallback(() => {
openChat({ id: modal!.userId });
handleClose();
});
const handleOpenUrl = useLastCallback(() => {
openUrl({
url: modal!.url,
shouldSkipModal: true,
});
handleClose();
});
const handleCopy = useLastCallback(() => {
const text = isUsername ? formatUsername(modal!.collectible)
: formatPhoneNumberWithCode(phoneCodeList, modal!.collectible);
copyTextToClipboard(text);
showNotification({
message: lang(isUsername ? 'UsernameCopied' : 'PhoneCopied'),
});
handleClose();
});
const title = useMemo(() => {
if (!modal) return undefined;
const key = isUsername ? 'FragmentUsernameTitle' : 'FragmentPhoneTitle';
const formattedCollectible = isUsername
? formatUsername(modal.collectible)
: formatPhoneNumberWithCode(phoneCodeList, modal.collectible);
return lang(key, formattedCollectible);
}, [modal, isUsername, phoneCodeList, lang]);
const description = useMemo(() => {
if (!modal) return undefined;
const key = isUsername ? 'FragmentUsernameMessage' : 'FragmentPhoneMessage';
const date = formatDateAtTime(lang, modal.purchaseDate * 1000);
const currency = formatCurrency(modal.amount, modal.currency, lang.code);
const cryptoCurrency = formatCurrency(modal.cryptoAmount, modal.cryptoCurrency, lang.code);
const paid = `${cryptoCurrency} (${currency})`;
return lang(key, [date, paid]);
}, [modal, isUsername, lang]);
return (
<Modal
isOpen={Boolean(modal)}
isSlim
contentClassName={styles.content}
onClose={closeCollectibleInfoModal}
>
<Button
round
color="translucent"
size="smaller"
className={styles.closeButton}
ariaLabel={lang('Close')}
onClick={handleClose}
>
<Icon name="close" />
</Button>
<div className={styles.icon}>
<AnimatedIconWithPreview
tgsUrl={isUsername ? LOCAL_TGS_URLS.Mention : LOCAL_TGS_URLS.Fragment}
size={TOP_ICON_SIZE}
/>
</div>
<h3 className={styles.title}>
{title && renderText(title, ['simple_markdown'])}
</h3>
<PickerSelectedItem
fluid
className={styles.chip}
peerId={modal?.userId}
forceShowSelf
onClick={handleOpenChat}
/>
<p className={styles.description}>
{description && renderText(description, ['simple_markdown'])}
</p>
<div className="dialog-buttons">
<Button className="confirm-dialog-button" onClick={handleOpenUrl}>
{lang('FragmentUsernameOpen')}
</Button>
<Button isText className="confirm-dialog-button" onClick={handleCopy}>
{lang(isUsername ? 'FragmentUsernameCopy' : 'FragmentPhoneCopy')}
</Button>
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { countryList } = global;
return {
phoneCodeList: countryList.phoneCodes,
};
},
)(CollectibleInfoModal));

View File

@ -319,6 +319,8 @@ export const GIVEAWAY_MAX_ADDITIONAL_CHANNELS = 10;
export const GIVEAWAY_MAX_ADDITIONAL_USERS = 10;
export const GIVEAWAY_MAX_ADDITIONAL_COUNTRIES = 10;
export const BOOST_PER_SENT_GIFT = 3;
export const FRAGMENT_PHONE_CODE = '888';
export const FRAGMENT_PHONE_LENGTH = 11;
export const LIGHT_THEME_BG_COLOR = '#99BA92';
export const DARK_THEME_BG_COLOR = '#0F0F0F';

View File

@ -30,6 +30,7 @@ import {
TOPICS_SLICE,
TOPICS_SLICE_SECOND_LOAD,
} from '../../../config';
import { copyTextToClipboard } from '../../../util/clipboard';
import { formatShareText, parseChooseParameter, processDeepLink } from '../../../util/deeplink';
import { isDeepLink } from '../../../util/deepLinkParser';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
@ -2673,6 +2674,38 @@ addActionHandler('resolveBusinessChatLink', async (global, actions, payload): Pr
});
});
addActionHandler('requestCollectibleInfo', async (global, actions, payload): Promise<void> => {
const {
type, collectible, userId, tabId = getCurrentTabId(),
} = payload;
let inputCollectible;
if (type === 'phone') {
inputCollectible = { phone: collectible };
}
if (type === 'username') {
inputCollectible = { username: collectible };
}
if (!inputCollectible) return;
const result = await callApi('fetchCollectionInfo', inputCollectible);
if (!result) {
copyTextToClipboard(collectible);
return;
}
global = getGlobal();
global = updateTabState(global, {
collectibleInfoModal: {
...result,
type,
collectible,
userId,
},
}, tabId);
setGlobal(global);
});
async function loadChats(
listType: ChatListType,
offsetId?: string,

View File

@ -744,6 +744,13 @@ addActionHandler('closeInviteViaLinkModal', (global, actions, payload): ActionRe
}, tabId);
});
addActionHandler('closeCollectibleInfoModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload ?? {};
return updateTabState(global, {
collectibleInfoModal: undefined,
}, tabId);
});
addActionHandler('setShouldCloseRightColumn', (global, actions, payload): ActionReturnType => {
const { value, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {

View File

@ -16,6 +16,7 @@ import type {
ApiChatReactions,
ApiChatType,
ApiCheckedGiftCode,
ApiCollectionInfo,
ApiConfig,
ApiContact,
ApiCountry,
@ -738,6 +739,12 @@ export type TabState = {
oneTimeMediaModal?: {
message: ApiMessage;
};
collectibleInfoModal?: ApiCollectionInfo & {
userId: string;
type: 'phone' | 'username';
collectible: string;
};
};
export type GlobalState = {
@ -2867,6 +2874,13 @@ export interface ActionPayloads {
openOneTimeMediaModal: TabState['oneTimeMediaModal'] & WithTabId;
closeOneTimeMediaModal: WithTabId | undefined;
requestCollectibleInfo: {
userId: string;
type : 'phone' | 'username';
collectible: string;
} & WithTabId;
closeCollectibleInfoModal: WithTabId | undefined;
// Calls
joinGroupCall: {
chatId?: string;

View File

@ -1637,4 +1637,5 @@ stories.togglePeerStoriesHidden#bd0415c4 peer:InputPeer hidden:Bool = Bool;
premium.getBoostsList#60f67660 flags:# gifts:flags.0?true peer:InputPeer offset:string limit:int = premium.BoostsList;
premium.getMyBoosts#be77b4a = premium.MyBoosts;
premium.applyBoost#6b7da746 flags:# slots:flags.0?Vector<int> peer:InputPeer = premium.MyBoosts;
premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus;`;
premium.getBoostsStatus#42f1f61 peer:InputPeer = premium.BoostsStatus;
fragment.getCollectibleInfo#be1e85ba collectible:InputCollectible = fragment.CollectibleInfo;`;

View File

@ -349,5 +349,6 @@
"payments.applyGiftCode",
"payments.getGiveawayInfo",
"payments.getPremiumGiftCodeOptions",
"payments.launchPrepaidGiveaway"
"payments.launchPrepaidGiveaway",
"fragment.getCollectibleInfo"
]

View File

@ -24,6 +24,9 @@ export function formatCurrency(
}
function getCurrencyExp(currency: string) {
if (currency === 'TON') {
return 9;
}
if (currency === 'CLF') {
return 4;
}