diff --git a/.i18n-codegen.json b/.i18n-codegen.json index ded70c3006bd..d54cdcd12b53 100644 --- a/.i18n-codegen.json +++ b/.i18n-codegen.json @@ -229,6 +229,18 @@ "trans": "Translate", "sourceMap": "inline" } + }, + { + "input": "./packages/mask/src/plugins/Avatar/locales/en-US.json", + "output": "./packages/mask/src/plugins/Avatar/locales/i18n_generated", + "parser": "i18next", + "generator": { + "type": "i18next/react-hooks", + "hooks": "useI18N", + "namespace": "com.maskbook.avatar", + "trans": "Translate", + "sourceMap": "inline" + } } ] } diff --git a/cspell.json b/cspell.json index 9e1391ac7183..b26de3d954c0 100644 --- a/cspell.json +++ b/cspell.json @@ -120,6 +120,7 @@ "multicall", "multihop", "newsfeed", + "nftavatar", "nftrss", "nftscan", "nums", @@ -215,7 +216,7 @@ "aicanft", "algr", "anft", - "avator", + "armv", "blocto", "bsct", "btcb", diff --git a/packages/icons/plugins/NFTAvatar.tsx b/packages/icons/plugins/NFTAvatar.tsx new file mode 100644 index 000000000000..4ba11fbe2ed0 --- /dev/null +++ b/packages/icons/plugins/NFTAvatar.tsx @@ -0,0 +1,29 @@ +import { createIcon } from '../utils' +import type { SvgIcon } from '@mui/material' + +export const NFTAvatarIcon: typeof SvgIcon = createIcon( + 'nft_avatar', + + + + + + + + + + , + '0 0 37 36', +) diff --git a/packages/icons/plugins/index.ts b/packages/icons/plugins/index.ts index 042cbb8b08a1..3915a4d0166c 100644 --- a/packages/icons/plugins/index.ts +++ b/packages/icons/plugins/index.ts @@ -24,5 +24,6 @@ export * from './DHEDGEIcon' export * from './PoolTogetherIcon' export * from './SecurityChecker' export * from './ArtBlocks' +export * from './NFTAvatar' export * from './connect' export * from './shared' diff --git a/packages/mask/package.json b/packages/mask/package.json index 25d28a0b9d5a..68d24f9efcda 100644 --- a/packages/mask/package.json +++ b/packages/mask/package.json @@ -56,6 +56,7 @@ "@types/json-stable-stringify": "^1.0.34", "@types/json2csv": "^5.0.3", "@types/node": "^17.0.31", + "@types/react-avatar-editor": "^10.3.6", "@types/react-highlight-words": "^0.16.4", "@types/react-router-dom": "^5.3.3", "@types/react-virtualized-auto-sizer": "^1.0.1", @@ -100,6 +101,7 @@ "pure-react-carousel": "^1.28.1", "promievent": "^0.1.4", "protobufjs": "^6.11.2", + "react-avatar-editor": "^12.0.0", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-draggable": "^4.4.4", diff --git a/packages/mask/shared-ui/locales/en-US.json b/packages/mask/shared-ui/locales/en-US.json index 5fee71e29b8f..5471db0b8fa6 100644 --- a/packages/mask/shared-ui/locales/en-US.json +++ b/packages/mask/shared-ui/locales/en-US.json @@ -336,12 +336,14 @@ "plugin_wallet_name_placeholder": "Enter 1-12 characters", "plugin_wallet_fail_to_sign": "Failed to sign password.", "plugin_wallet_cancel_sign": "Signature canceled.", + "plugin_nft_avatar_recommend_feature_description": "Set your NFT as profile picture with exclusive aura", "plugin_red_packet_display_name": "Plugin: Lucky Drop", + "application_hint": "Socialize and show off your NFTs. People can bid,buy, view your valuable NFTs without leaving Twitter.", "plugin_red_packet_claimed": "Claimed", "plugin_red_packet_erc20_tab_title": "Token", "plugin_red_packet_erc721_tab_title": "Collectibles", "plugin_red_packet_name": "Lucky Drop", - "plugin_red_packet_recommend_feature_description": "Surpirse your social friends with Lucky Drop", + "plugin_red_packet_recommend_feature_description": "Astonish your friends with a surprise gift", "plugin_red_packet_description": "Gift crypto or NFTs to any users, first come, first served.", "plugin_red_packet_erc721_insufficient_balance": "Insufficient Balance", "plugin_red_packet_details": "Lucky Drop Details", @@ -950,8 +952,6 @@ "popups_social_account": "Social Accounts", "popups_connected_wallets": "Connected Wallets", "popups_loading": "Loading", - "popups_wallet_disconnect_success": "Wallet disconnect successfully", - "popups_wallet_disconnect_failed": "Wallet disconnect failed", "popups_connect_your_web2_social_accounts": "Connect your web2 social accounts", "popups_connect_social_account": "Connect Social Account", "popups_disconnect_success": " Disconnect successfully. ", @@ -1018,6 +1018,7 @@ "popups_wallet_token_buy": "Buy", "popups_wallet_token_send": "Send", "popups_wallet_token_swap": "Swap", + "popups_wallet_disconnect_failed": "Wallet disconnect failed", "popups_wallet_view_on_explorer": "View on Explorer", "popups_wallet_gas_fee_settings": "Gas fee settings", "popups_wallet_gas_fee_settings_description": "Gas fees are the fees for paying ethereum miners. The miners prefer to pack transactions with higher gas fees. Transactions with low gas fees might fail, and the paid gas fees won’t be returned.", @@ -1029,7 +1030,6 @@ "popups_wallet_gas_fee_settings_max_priority_fee": "Max priority fee", "popups_wallet_gas_fee_settings_max_fee": "Max fee", "popups_wallet_gas_fee_settings_min_gas_limit_tips": "Gas limit must be at least {{limit}}", - "popups_wallet_unsupported_network": "Unsupported network type", "popups_wallet_signature_request_title": "Signature Request", "popups_wallet_signature_request": "Signature request", "popups_wallet_signature_request_message": "Message", @@ -1075,13 +1075,13 @@ "plugin_vcent_last_offer_at": "LATEST OFFER at", "nft_input_address_label": "Please input contract address", "nft_input_tokenid_label": "Please input token ID", - "nft_owner_hint": "This NFT does not exist or does not belong to you.", + "nft_owner_hint": "The contract address is incorrect or the collection does not belong to you", "nft_add_dialog_title": "Add Collectible", "nft_add_button_label": "Add", "nft_list_title": "NFT Avatar Setting", "nft_wallet_change": "Change", "nft_button_add_collectible": "Add Collectible", - "nft_avatar": "NFT Avatar", + "nft_avatar": "NFT PFP", "web3_tab_hint": "No address found.", "plugin_artblocks_not_active": "This project is no more active", "plugin_artblocks_completed": "Completed", @@ -1104,6 +1104,7 @@ "plugin_artblocks_blockchain_row": "Blockchain:", "plugin_artblocks_contract_row": "Contract:", "plugin_artblocks_smt_wrong": "Something went wrong!", + "popups_wallet_disconnect_success": "Wallet disconnect successfully", "plugin_artblocks_share": "I just purchased a beautiful piece of art from '{{name}}' collection for {{price}} {{symbol}}. Install @realMaskNetwork to get yours.", "plugin_artblocks_share_no_official_account": "I just purchased a beautiful piece of art from '{{name}}' collection for {{price}} {{symbol}}. Welcome to join.", "plugin_tips_disconnect_introduction": "Are you going to disconnect {{persona}} from wallet {{address}}?", @@ -1121,6 +1122,7 @@ "plugin_transak_description": "Fiat On-Ramp Aggregator on Twitter. Buy crypto in 60+ countries with Transak support.", "plugin_valuables_name": "Valuables", "plugin_valuables_description": "Buy & sell tweets autographed by their original creators.", + "popups_wallet_unsupported_network": "Unsupported network type", "plugin_default_title": "Default", "plugin_provider_by": "Provided by", "plugin_enables": "Enable plugins" diff --git a/packages/mask/shared-ui/locales/ko-KR.json b/packages/mask/shared-ui/locales/ko-KR.json index 309f9b81d1b8..03f0790092aa 100644 --- a/packages/mask/shared-ui/locales/ko-KR.json +++ b/packages/mask/shared-ui/locales/ko-KR.json @@ -721,7 +721,6 @@ "popups_wallet_gas_fee_settings_max_priority_fee": "최대 우선 가스비", "popups_wallet_gas_fee_settings_max_fee": "최대 가스비", "popups_wallet_gas_fee_settings_min_gas_limit_tips": "최소한 가스비는 {{limit}} 입니다", - "popups_wallet_unsupported_network": "지원되지 않는 네트워크", "popups_wallet_signature_request": "서명", "popups_wallet_signature_request_message": "메시지", "popups_wallet_contract_interaction": "컨트렉트 인터액션", diff --git a/packages/mask/shared-ui/locales/qya-AA.json b/packages/mask/shared-ui/locales/qya-AA.json index fa190e5951ae..2d3a312335e6 100644 --- a/packages/mask/shared-ui/locales/qya-AA.json +++ b/packages/mask/shared-ui/locales/qya-AA.json @@ -948,8 +948,6 @@ "popups_social_account": "crwdns16328:0crwdne16328:0", "popups_connected_wallets": "crwdns16330:0crwdne16330:0", "popups_loading": "crwdns16332:0crwdne16332:0", - "popups_wallet_disconnect_success": "crwdns16334:0crwdne16334:0", - "popups_wallet_disconnect_failed": "crwdns16336:0crwdne16336:0", "popups_connect_your_web2_social_accounts": "crwdns16338:0crwdne16338:0", "popups_connect_social_account": "crwdns16340:0crwdne16340:0", "popups_disconnect_success": "crwdns16342:0crwdne16342:0", @@ -1027,7 +1025,6 @@ "popups_wallet_gas_fee_settings_max_priority_fee": "crwdns7857:0crwdne7857:0", "popups_wallet_gas_fee_settings_max_fee": "crwdns7859:0crwdne7859:0", "popups_wallet_gas_fee_settings_min_gas_limit_tips": "crwdns8993:0{{limit}}crwdne8993:0", - "popups_wallet_unsupported_network": "crwdns8995:0crwdne8995:0", "popups_wallet_signature_request_title": "crwdns16386:0crwdne16386:0", "popups_wallet_signature_request": "crwdns7861:0crwdne7861:0", "popups_wallet_signature_request_message": "crwdns8059:0crwdne8059:0", diff --git a/packages/mask/shared-ui/locales/zh-CN.json b/packages/mask/shared-ui/locales/zh-CN.json index 8794b0935615..74cde5626191 100644 --- a/packages/mask/shared-ui/locales/zh-CN.json +++ b/packages/mask/shared-ui/locales/zh-CN.json @@ -837,7 +837,6 @@ "popups_wallet_gas_fee_settings_max_priority_fee": "Max priority fee", "popups_wallet_gas_fee_settings_max_fee": "Max fee", "popups_wallet_gas_fee_settings_min_gas_limit_tips": "Gas limit 必须至少 {{limit}}", - "popups_wallet_unsupported_network": "不支持的网络类型", "popups_wallet_signature_request": "签名请求", "popups_wallet_signature_request_message": "信息", "popups_wallet_contract_interaction": "合约交互", diff --git a/packages/mask/src/components/shared/ApplicationBoard.tsx b/packages/mask/src/components/shared/ApplicationBoard.tsx index 9f8b3016c9ae..bba3a2fd9cfc 100644 --- a/packages/mask/src/components/shared/ApplicationBoard.tsx +++ b/packages/mask/src/components/shared/ApplicationBoard.tsx @@ -4,7 +4,7 @@ import { Typography } from '@mui/material' import { useChainId } from '@masknet/web3-shared-evm' import { useActivatedPluginsSNSAdaptor } from '@masknet/plugin-infra/content-script' import { useCurrentWeb3NetworkPluginID, useAccount, NetworkPluginID } from '@masknet/plugin-infra/web3' -import { CrossIsolationMessages, formatPersonaPublicKey } from '@masknet/shared-base' +import { formatPersonaPublicKey } from '@masknet/shared-base' import { getCurrentSNSNetwork } from '../../social-network-adaptor/utils' import { activatedSocialNetworkUI } from '../../social-network' import { useI18N } from '../../utils' @@ -31,7 +31,7 @@ const useStyles = makeStyles<{ shouldScroll: boolean }>()((theme, props) => { gridGap: 10, justifyContent: 'space-between', height: 320, - width: props.shouldScroll ? 575 : 562, + width: props.shouldScroll ? 589 : 576, '::-webkit-scrollbar': { backgroundColor: 'transparent', width: 20, @@ -145,7 +145,9 @@ function ApplicationBoardContent(props: Props) { [snsAdaptorPlugins, currentWeb3Network, chainId, account], ) - const recommendFeatureAppList = applicationList.filter((x) => x.entry.recommendFeature) + const recommendFeatureAppList = applicationList + .filter((x) => x.entry.recommendFeature) + .sort((a, b) => (a.entry.appBoardSortingDefaultPriority ?? 0) - (b.entry.appBoardSortingDefaultPriority ?? 0)) const listedAppList = applicationList.filter((x) => !x.entry.recommendFeature).filter((x) => !getUnlistedApp(x)) const { classes } = useStyles({ shouldScroll: listedAppList.length > 12 }) @@ -208,7 +210,7 @@ function RenderEntryComponent({ application }: { application: Application }) { const verifyPersona = useCallback(() => { closeApplicationBoard() - CrossIsolationMessages.events.verifyNextID.sendToAll(undefined) + ApplicationEntryStatus.personaNextIDReset?.() }, []) const clickHandler = (() => { @@ -225,6 +227,7 @@ function RenderEntryComponent({ application }: { application: Application }) { // #region tooltip hint const tooltipHint = (() => { + if (ApplicationEntryStatus.isLoading) return if (application.isWalletConnectedRequired) return t('application_tooltip_hint_connect_wallet') if (application.isWalletConnectedEVMRequired) return t('application_tooltip_hint_switch_to_evm_wallet') if (!application.entry.nextIdRequired) return @@ -261,6 +264,7 @@ interface ApplicationEntryStatusContextProps { currentPersonaPublicKey: string | undefined currentSNSConnectedPersonaPublicKey: string | undefined personaConnectAction: (() => void) | undefined + personaNextIDReset: (() => void) | undefined isLoading: boolean } @@ -274,6 +278,7 @@ const ApplicationEntryStatusContext = createContext) { const personaConnectStatus = usePersonaConnectStatus() const nextIDConnectStatus = useNextIDConnectStatus() - const { value: ApplicationCurrentStatus, retry } = usePersonaAgainstSNSConnectStatus() + const { + value: ApplicationCurrentStatus, + retry, + loading: personaAgainstSNSConnectStatusLoading, + } = usePersonaAgainstSNSConnectStatus() useEffect(() => { return MaskMessages.events.currentPersonaIdentifier.on(retry) @@ -294,6 +303,7 @@ function ApplicationEntryStatusProvider(props: PropsWithChildren<{}>) { ) { shouldVerifyNextId: Boolean(!nextIDConnectStatus.isVerified && ApplicationCurrentStatus), currentPersonaPublicKey, currentSNSConnectedPersonaPublicKey, - isLoading: nextIDConnectStatus.loading, + isLoading: nextIDConnectStatus.loading || personaAgainstSNSConnectStatusLoading, }}> {props.children} diff --git a/packages/mask/src/components/shared/ApplicationSettingPluginList.tsx b/packages/mask/src/components/shared/ApplicationSettingPluginList.tsx index 5e5d47c7e912..e1ae1c7860e9 100644 --- a/packages/mask/src/components/shared/ApplicationSettingPluginList.tsx +++ b/packages/mask/src/components/shared/ApplicationSettingPluginList.tsx @@ -24,7 +24,7 @@ export function getUnlistedApp(app: Application): boolean { } // #endregion -const useStyles = makeStyles()((theme) => ({ +const useStyles = makeStyles<{ iconFilterColor?: string }>()((theme, { iconFilterColor }) => ({ list: { display: 'grid', gap: theme.spacing(2, 1), @@ -54,6 +54,9 @@ const useStyles = makeStyles()((theme) => ({ width: 36, height: 36, }, + ...(iconFilterColor + ? { filter: `drop-shadow(0px 6px 12px ${iconFilterColor})`, backdropFilter: 'blur(16px)' } + : {}), }, loadingWrapper: { display: 'flex', @@ -77,7 +80,7 @@ const useStyles = makeStyles()((theme) => ({ })) export function ApplicationSettingPluginList() { - const { classes } = useStyles() + const { classes } = useStyles({ iconFilterColor: undefined }) const { t } = useI18N() const snsAdaptorPlugins = useActivatedPluginsSNSAdaptor('any') const applicationList = useMemo(() => { @@ -123,7 +126,7 @@ interface AppListProps { function AppList(props: AppListProps) { const { appList, setUnlistedApp, isListed } = props - const { classes } = useStyles() + const { classes } = useStyles({ iconFilterColor: undefined }) const { t } = useI18N() return appList.length > 0 ? ( @@ -156,7 +159,7 @@ interface AppListItemProps { function AppListItem(props: AppListItemProps) { const { application, setUnlistedApp, isListed } = props - const { classes } = useStyles() + const { classes } = useStyles({ iconFilterColor: application.entry.iconFilterColor }) return ( setUnlistedApp(application, isListed)}>
{application.entry.icon}
diff --git a/packages/mask/src/extension/popups/pages/Personas/ConnectedWallets/index.tsx b/packages/mask/src/extension/popups/pages/Personas/ConnectedWallets/index.tsx index b47e4de10d78..09e057afa2b9 100644 --- a/packages/mask/src/extension/popups/pages/Personas/ConnectedWallets/index.tsx +++ b/packages/mask/src/extension/popups/pages/Personas/ConnectedWallets/index.tsx @@ -12,12 +12,15 @@ import type { ConnectedWalletInfo } from '../type' import { NextIDProof } from '@masknet/web3-providers' import Service from '../../../../service' import { usePopupCustomSnackbar } from '@masknet/theme' +import { useLocation, useNavigate } from 'react-router-dom' const ConnectedWallets = memo(() => { const { t } = useI18N() const chainId = useChainId() const { NameService } = useWeb3State() const wallets = useWallets() + const navigate = useNavigate() + const location = useLocation() const { proofs, currentPersona, refreshProofs, fetchProofsLoading } = PersonaContext.useContainer() const { showSnackbar } = usePopupCustomSnackbar() @@ -107,6 +110,13 @@ const ConnectedWallets = memo(() => { ) const navigateToConnectWallet = async () => { + const params = new URLSearchParams(location.search) + const internal = params.get('internal') + + if (internal) { + navigate(PopupRoutes.ConnectWallet) + return + } await Service.Helper.openPopupWindow(PopupRoutes.ConnectWallet) window.close() } diff --git a/packages/mask/src/plugins/Avatar/Application/NFTAvatar.tsx b/packages/mask/src/plugins/Avatar/Application/NFTAvatar.tsx new file mode 100644 index 000000000000..33205b509739 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/NFTAvatar.tsx @@ -0,0 +1,46 @@ +import { EnhanceableSite } from '@masknet/shared-base' +import { Avatar, Stack } from '@mui/material' +import { PointIcon } from '../assets/point' +import { TwitterIcon } from '../assets/twitter' +import { RainbowBox } from '../SNSAdaptor/RainbowBox' + +export const SOCIAL_MEDIA_ICON_MAPPING: Record = { + [EnhanceableSite.Twitter]: , + [EnhanceableSite.Localhost]: null, +} + +interface NFTAvatarProps { + hasBorder: boolean + platform?: string + avatar?: string + owner?: boolean +} + +export function NFTAvatar(props: NFTAvatarProps) { + const { avatar, hasBorder, platform = '', owner = false } = props + + return ( + + {hasBorder ? ( + + + + ) : ( + + )} + + + {SOCIAL_MEDIA_ICON_MAPPING[EnhanceableSite.Twitter]} + + {owner ? : null} + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/NFTAvatarsDialog.tsx b/packages/mask/src/plugins/Avatar/Application/NFTAvatarsDialog.tsx new file mode 100644 index 000000000000..d76817e316b6 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/NFTAvatarsDialog.tsx @@ -0,0 +1,106 @@ +import { useCallback, useState } from 'react' +import { NFTListDialog } from './NFTListDialog' +import { InjectedDialog } from '@masknet/shared' +import { UploadAvatarDialog } from './UploadAvatarDialog' +import type { BindingProof } from '@masknet/shared-base' +import type { SelectTokenInfo, TokenInfo } from '../types' +import { PersonaPage } from './PersonaPage' +import { DialogContent } from '@mui/material' +import { useI18N } from '../locales/i18n_generated' +import { isSameAddress } from '@masknet/web3-shared-evm' +import { makeStyles } from '@masknet/theme' + +const useStyles = makeStyles()((theme) => ({ + root: { + margin: 0, + padding: '1px !important', + '::-webkit-scrollbar': { + backgroundColor: 'transparent', + width: 20, + }, + '::-webkit-scrollbar-thumb': { + borderRadius: '20px', + width: 5, + border: '7px solid rgba(0, 0, 0, 0)', + backgroundColor: theme.palette.mode === 'dark' ? 'rgba(250, 250, 250, 0.2)' : 'rgba(0, 0, 0, 0.2)', + backgroundClip: 'padding-box', + }, + }, +})) +enum CreateNFTAvatarStep { + Persona = 'persona', + NFTList = 'NFTList', + UploadAvatar = 'UploadAvatar', +} + +interface NFTAvatarsDialogProps { + open: boolean + onClose: () => void +} + +export function NFTAvatarDialog(props: NFTAvatarsDialogProps) { + const [step, setStep] = useState(CreateNFTAvatarStep.Persona) + const [wallets, setWallets] = useState() + const [selectedTokenInfo, setSelectedTokenInfo] = useState() + const [tokenInfo, setTokenInfo] = useState() + const [proof, setProof] = useState() + const t = useI18N() + const { classes } = useStyles() + + const onPersonaChange = (proof: BindingProof, wallets?: BindingProof[], tokenInfo?: TokenInfo) => { + setWallets(wallets) + setTokenInfo(tokenInfo) + setProof(proof) + } + + const onSelected = (info: SelectTokenInfo) => { + setSelectedTokenInfo(info) + } + + const onNext = useCallback(() => { + if (step === CreateNFTAvatarStep.Persona) setStep(CreateNFTAvatarStep.NFTList) + else if (step === CreateNFTAvatarStep.NFTList) setStep(CreateNFTAvatarStep.UploadAvatar) + }, [step]) + + const onBack = useCallback(() => { + if (step === CreateNFTAvatarStep.UploadAvatar) setStep(CreateNFTAvatarStep.NFTList) + else if (step === CreateNFTAvatarStep.NFTList) setStep(CreateNFTAvatarStep.Persona) + else props.onClose() + }, [step]) + + const onClose = useCallback(() => { + setStep(CreateNFTAvatarStep.Persona) + props.onClose() + }, [props.onClose]) + + return ( + + + {step === CreateNFTAvatarStep.Persona ? ( + + ) : null} + {step === CreateNFTAvatarStep.NFTList ? ( + + ) : null} + {step === CreateNFTAvatarStep.UploadAvatar ? ( + isSameAddress(x.identity, selectedTokenInfo?.account))} + account={selectedTokenInfo?.account} + image={selectedTokenInfo?.image} + token={selectedTokenInfo?.token} + onBack={onBack} + onClose={onClose} + /> + ) : null} + + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/NFTInfo.tsx b/packages/mask/src/plugins/Avatar/Application/NFTInfo.tsx new file mode 100644 index 000000000000..77647436ee5a --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/NFTInfo.tsx @@ -0,0 +1,65 @@ +import { Box, CircularProgress, Link, Stack, Typography } from '@mui/material' +import { makeStyles, useStylesExtends } from '@masknet/theme' +import { LinkIcon } from '../assets/link' +import { ChainId, resolveOpenSeaLink } from '@masknet/web3-shared-evm' +import { formatTokenId } from '../utils' +import { useI18N } from '../locales/i18n_generated' +import { ApplicationRoundIcon } from '../assets/application' +import { formatPersonaName } from '@masknet/shared-base' + +const useStyles = makeStyles()(() => ({ + root: { + width: 160, + }, + nft: { + display: 'flex', + alignItems: 'center', + }, +})) +interface NFTInfoProps extends withClasses<'root'> { + nft?: { name: string; address: string; tokenId: string; symbol: string; chainId: ChainId } + owner: boolean + loading?: boolean +} + +export function NFTInfo(props: NFTInfoProps) { + const { nft, owner, loading = false } = props + const classes = useStylesExtends(useStyles(), props) + const t = useI18N() + + if (loading) return + return ( + + {!nft ? ( + + {t.persona_set_nft()} + + ) : !owner ? ( + + {t.persona_verification_failed()} + + ) : ( + + + + + {formatPersonaName(nft.name.replace(/#\d+/, ''))} + + + + {formatTokenId(nft.symbol, nft.tokenId)} + + + + + + + + )} + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/NFTList.tsx b/packages/mask/src/plugins/Avatar/Application/NFTList.tsx new file mode 100644 index 000000000000..eb9615527ce4 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/NFTList.tsx @@ -0,0 +1,103 @@ +import { makeStyles, useTabs } from '@masknet/theme' +import { ChainId, ERC721TokenDetailed } from '@masknet/web3-shared-evm' +import { TabContext, TabPanel } from '@mui/lab' +import { Tab, Tabs, Typography } from '@mui/material' +import { Application_NFT_LIST_PAGE, SUPPORTED_CHAIN_IDS } from '../constants' +import type { TokenInfo } from '../types' +import { NFTListPage } from './NFTListPage' + +const useStyles = makeStyles<{ currentTab: Application_NFT_LIST_PAGE }>()((theme, props) => ({ + selected: { + backgroundColor: theme.palette.mode === 'dark' ? 'black' : 'white', + border: 'none', + borderTop: `1px solid ${theme.palette.mode === 'dark' ? '#2F3336' : '#EFF3F4'}`, + color: `${theme.palette.text.primary} !important`, + minHeight: 37, + height: 37, + zIndex: 1, + }, + tab: { + backgroundColor: theme.palette.mode === 'dark' ? '#15171A' : '#F6F8F8', + color: theme.palette.text.secondary, + border: `1px solid ${theme.palette.mode === 'dark' ? '#2F3336' : '#EFF3F4'}`, + minHeight: 37, + height: 37, + zIndex: 1, + }, + tabPanel: { + padding: theme.spacing(1), + paddingTop: 50, + paddingBottom: 80, + }, +})) +interface NFTListProps { + address: string + tokenInfo?: TokenInfo + onSelect: (token: ERC721TokenDetailed) => void + onChangePage?: (page: Application_NFT_LIST_PAGE) => void + tokens?: ERC721TokenDetailed[] + children?: React.ReactElement +} + +export function NFTList(props: NFTListProps) { + const { address, onSelect, tokenInfo, onChangePage, tokens = [], children } = props + + const [currentTab, onChange, tabs] = useTabs( + Application_NFT_LIST_PAGE.Application_nft_tab_eth_page, + Application_NFT_LIST_PAGE.Application_nft_tab_polygon_page, + ) + + const { classes } = useStyles({ currentTab }) + const _onChange = (event: unknown, value: any) => { + onChange(event, value) + onChangePage?.(value) + } + + if (!address) return null + return ( + + + {SUPPORTED_CHAIN_IDS.map((x, i) => { + const curChainId = currentTab === tabs.ETH ? ChainId.Mainnet : ChainId.Matic + return ( + + {x === ChainId.Mainnet + ? Application_NFT_LIST_PAGE.Application_nft_tab_eth_page + : Application_NFT_LIST_PAGE.Application_nft_tab_polygon_page} + + } + value={x === ChainId.Mainnet ? tabs.ETH : tabs.Polygon} + className={curChainId === x ? classes.selected : classes.tab} + /> + ) + })} + + {SUPPORTED_CHAIN_IDS.map((x, i) => ( + + y.contractDetailed.chainId === x) ?? []} + tokenInfo={tokenInfo} + chainId={x} + address={address} + onSelect={onSelect} + children={children} + /> + + ))} + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/NFTListDialog.tsx b/packages/mask/src/plugins/Avatar/Application/NFTListDialog.tsx new file mode 100644 index 000000000000..1488f4fc3deb --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/NFTListDialog.tsx @@ -0,0 +1,279 @@ +import { makeStyles, useCustomSnackbar } from '@masknet/theme' +import { ChainId, ERC721TokenDetailed, isSameAddress, SocketState, useCollectibles } from '@masknet/web3-shared-evm' +import { Box, Button, DialogActions, DialogContent, Skeleton, Stack, Typography } from '@mui/material' +import { useCallback, useState, useEffect } from 'react' +import { downloadUrl } from '../../../utils' +import { AddNFT } from '../SNSAdaptor/AddNFT' +import type { BindingProof } from '@masknet/shared-base' +import type { SelectTokenInfo, TokenInfo } from '../types' +import { range, uniqBy } from 'lodash-unified' +import { Translate, useI18N } from '../locales' +import { AddressNames } from './WalletList' +import { NFTList } from './NFTList' +import { Application_NFT_LIST_PAGE } from '../constants' +import { NetworkPluginID, useAccount, useCurrentWeb3NetworkPluginID } from '@masknet/plugin-infra/web3' +import { NFTWalletConnect } from './WalletConnect' + +const useStyles = makeStyles()((theme) => ({ + AddressNames: { + position: 'absolute', + top: 10, + right: 10, + }, + + button: { + width: 219.5, + borderRadius: 999, + }, + AddCollectiblesButton: { + fontWeight: 600, + color: '#1D9BF0', + }, + actions: { + padding: theme.spacing(2), + backgroundColor: theme.palette.mode === 'dark' ? 'black' : 'white', + position: 'absolute', + left: 0, + bottom: 0, + width: 'calc(100% - 32px)', + }, + content: { + height: 612, + padding: 0, + backgroundColor: theme.palette.mode === 'dark' ? 'black' : 'white', + }, + error: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + margin: 'auto', + paddingTop: 260, + }, + skeleton: { + width: 97, + height: 97, + objectFit: 'cover', + borderRadius: '100%', + boxSizing: 'border-box', + padding: 6, + margin: theme.spacing(0.5, 1), + }, + skeletonBox: { + marginLeft: 'auto', + marginRight: 'auto', + }, + gallery: { + display: 'flex', + flexFlow: 'row wrap', + overflowY: 'auto', + '&::-webkit-scrollbar': { + display: 'none', + }, + }, +})) + +function isSameToken(token?: ERC721TokenDetailed, tokenInfo?: TokenInfo) { + if (!token && !tokenInfo) return false + return isSameAddress(token?.contractDetailed.address, tokenInfo?.address) && token?.tokenId === tokenInfo?.tokenId +} +interface NFTListDialogProps { + onNext: () => void + tokenInfo?: TokenInfo + wallets?: BindingProof[] + onSelected: (info: SelectTokenInfo) => void +} + +export function NFTListDialog(props: NFTListDialogProps) { + const { onNext, wallets, onSelected, tokenInfo } = props + const { classes } = useStyles() + + const account = useAccount() + const [open_, setOpen_] = useState(false) + const [selectedAccount, setSelectedAccount] = useState('') + const [selectedToken, setSelectedToken] = useState() + const [disabled, setDisabled] = useState(false) + const t = useI18N() + const [tokens, setTokens] = useState([]) + const [currentPage, setCurrentPage] = useState( + Application_NFT_LIST_PAGE.Application_nft_tab_eth_page, + ) + + const POLYGON_PAGE = Application_NFT_LIST_PAGE.Application_nft_tab_polygon_page + + const currentPluginId = useCurrentWeb3NetworkPluginID() + const { + data: collectibles, + error, + retry, + state, + } = useCollectibles(selectedAccount, currentPage !== POLYGON_PAGE ? ChainId.Mainnet : ChainId.Matic) + + const { showSnackbar } = useCustomSnackbar() + const onChange = useCallback((address: string) => { + setSelectedAccount(address) + }, []) + + const onSelect = (token: ERC721TokenDetailed) => { + setSelectedToken(token) + } + + const onSave = useCallback(async () => { + if (!selectedToken?.info?.imageURL) return + setDisabled(true) + try { + const image = await downloadUrl(selectedToken.info.imageURL) + onSelected({ image: URL.createObjectURL(image), account: selectedAccount, token: selectedToken }) + onNext() + setDisabled(false) + } catch (error) { + console.log(error) + } + setDisabled(false) + }, [selectedToken, selectedAccount]) + + const onClick = useCallback(() => { + if (!account && !wallets?.length) { + showSnackbar('Please connect your wallet!', { variant: 'error' }) + return + } + setOpen_(true) + }, [account, wallets, showSnackbar]) + + useEffect(() => { + setDisabled(!selectedToken || isSameToken(selectedToken, tokenInfo)) + }, [selectedToken, tokenInfo]) + + useEffect(() => setSelectedAccount(account || wallets?.[0]?.identity || ''), [account, wallets]) + + const onAddClick = (token: ERC721TokenDetailed) => { + setTokens((_tokens) => uniqBy([..._tokens, token], (x) => x.contractDetailed.address && x.tokenId)) + } + + const onChangePage = (name: Application_NFT_LIST_PAGE) => { + setCurrentPage(name) + setSelectedToken(undefined) + } + + const AddCollectible = ( + + + {currentPluginId !== NetworkPluginID.PLUGIN_EVM ? ( + , + }} + /> + ) : currentPage === POLYGON_PAGE ? ( + , + }} + /> + ) : ( + t.collectible_no_eth() + )} + + {currentPluginId === NetworkPluginID.PLUGIN_EVM ? ( + + ) : null} + + ) + + const LoadStatus = ( +
+ {range(8).map((i) => ( + + ))} +
+ ) + + const Retry = ( + + {t.no_collectible_found()} + + + ) + + const NoNFTList = () => { + if (currentPage === POLYGON_PAGE && tokens.length === 0) return AddCollectible + else if (currentPage === POLYGON_PAGE && tokens.length) return + if (state !== SocketState.done) { + return LoadStatus + } + if (error) { + return Retry + } + if (tokens.length === 0 && collectibles.length === 0) { + return AddCollectible + } + + return + } + + if (!wallets?.length && (currentPluginId !== NetworkPluginID.PLUGIN_EVM || !account)) + return ( + + + + ) + + return ( + <> + + + {((account && currentPluginId === NetworkPluginID.PLUGIN_EVM) || Boolean(wallets?.length)) && ( + x.contractDetailed.address && x.tokenId)} + children={NoNFTList()} + /> + )} + + + {tokens.length || collectibles.length ? ( + + + {t.collectible_not_found()} + + + {t.add_collectible()} + + + ) : null} + + + + setOpen_(false)} + onAddClick={onAddClick} + /> + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/NFTListPage.tsx b/packages/mask/src/plugins/Avatar/Application/NFTListPage.tsx new file mode 100644 index 000000000000..065ead46a817 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/NFTListPage.tsx @@ -0,0 +1,118 @@ +import { makeStyles } from '@masknet/theme' +import { + ChainId, + createERC721Token, + ERC721TokenDetailed, + EthereumTokenType, + useImageChecker, +} from '@masknet/web3-shared-evm' +import { Box, Skeleton } from '@mui/material' +import { useState } from 'react' +import { NFTImage } from '../SNSAdaptor/NFTImage' +import type { TokenInfo } from '../types' + +const useStyles = makeStyles()((theme) => ({ + root: {}, + + button: { + textAlign: 'center', + paddingTop: theme.spacing(1), + display: 'flex', + justifyContent: 'space-around', + flexDirection: 'row', + color: '#1D9BF0', + }, + gallery: { + display: 'flex', + flexFlow: 'row wrap', + overflowY: 'auto', + '&::-webkit-scrollbar': { + display: 'none', + }, + }, + skeleton: { + width: 97, + height: 97, + objectFit: 'cover', + borderRadius: '100%', + boxSizing: 'border-box', + padding: 6, + margin: theme.spacing(0.5, 1), + }, + skeletonBox: { + marginLeft: 'auto', + marginRight: 'auto', + }, +})) + +interface NFTListPageProps { + chainId: ChainId + address: string + tokenInfo?: TokenInfo + tokens: ERC721TokenDetailed[] + onSelect?: (token: ERC721TokenDetailed) => void + children?: React.ReactElement +} + +export function NFTListPage(props: NFTListPageProps) { + const { classes } = useStyles() + const { onSelect, chainId, tokenInfo, tokens, children } = props + const [selectedToken, setSelectedToken] = useState( + tokenInfo + ? createERC721Token( + { + name: '', + address: tokenInfo.address, + chainId, + symbol: '', + type: EthereumTokenType.ERC721, + }, + {}, + tokenInfo.tokenId, + ) + : undefined, + ) + + const onChange = (token: ERC721TokenDetailed) => { + if (!token) return + setSelectedToken(token) + onSelect?.(token) + } + + return ( + <> + + + {children ?? + tokens.map((token: ERC721TokenDetailed, i) => ( + onChange(token)} + /> + ))} + + + + ) +} + +interface NFTImageCollectibleAvatarProps { + token: ERC721TokenDetailed + onChange: (token: ERC721TokenDetailed) => void + selectedToken?: ERC721TokenDetailed +} + +function NFTImageCollectibleAvatar({ token, onChange, selectedToken }: NFTImageCollectibleAvatarProps) { + const { classes } = useStyles() + const { value: isImageToken, loading } = useImageChecker(token.info?.imageURL) + + if (loading) + return ( +
+ +
+ ) + return isImageToken ? : null +} diff --git a/packages/mask/src/plugins/Avatar/Application/PersonaItem.tsx b/packages/mask/src/plugins/Avatar/Application/PersonaItem.tsx new file mode 100644 index 000000000000..7aee1cc9a42c --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/PersonaItem.tsx @@ -0,0 +1,94 @@ +import { makeStyles } from '@masknet/theme' +import { Box, Typography, ListItemButton } from '@mui/material' +import { NFTAvatar } from './NFTAvatar' +import { NFTInfo } from './NFTInfo' +import { MoreIcon } from '../assets/more' +import { RSS3_KEY_SNS } from '../constants' +import { useCheckTokenOwner, useTokenOwner } from '../hooks/useTokenOwner' +import { getAvatarId } from '../../../social-network-adaptor/twitter.com/utils/user' +import type { TokenInfo } from '../types' +import { useCallback } from 'react' +import type { BindingProof } from '@masknet/shared-base' +import { usePersonaNFTAvatar } from '../hooks/usePersonaNFTAvatar' +import { ChainId } from '@masknet/web3-shared-evm' + +const useStyles = makeStyles<{ disabled: boolean }>()((theme, props) => ({ + root: { + margin: theme.spacing(2, 0.5), + border: `1px solid ${theme.palette.divider}`, + borderRadius: 16, + padding: 16, + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + }, + + userInfo: { + fontSize: 14, + marginLeft: 16, + flex: 1, + }, +})) + +interface PersonaItemProps { + owner?: boolean + avatar: string + userId: string + nickname?: string + proof?: BindingProof + onSelect?: (proof: BindingProof, tokenInfo?: TokenInfo) => void +} + +export function PersonaItem(props: PersonaItemProps) { + const { userId, onSelect, owner = false, proof, avatar, nickname = '' } = props + const { classes } = useStyles({ disabled: !owner }) + const { value: _avatar, loading } = usePersonaNFTAvatar(userId, getAvatarId(avatar) ?? '', RSS3_KEY_SNS.TWITTER) + const { value: token, loading: loadingToken } = useTokenOwner( + _avatar?.address ?? '', + _avatar?.tokenId ?? '', + _avatar?.chainId, + ) + const { loading: loadingCheckOwner, isOwner } = useCheckTokenOwner(userId, token?.owner) + + const onClick = useCallback(() => { + if (!proof) return + onSelect?.(proof, _avatar && isOwner ? { address: _avatar?.address, tokenId: _avatar?.tokenId } : undefined) + }, [_avatar, proof]) + + return ( + + + + + {nickname || _avatar?.nickname} + + + @{userId} + + + + + + + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/PersonaPage.tsx b/packages/mask/src/plugins/Avatar/Application/PersonaPage.tsx new file mode 100644 index 000000000000..38a67e5b0312 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/PersonaPage.tsx @@ -0,0 +1,107 @@ +import { BindingProof, NextIDPlatform } from '@masknet/shared-base' +import { makeStyles } from '@masknet/theme' +import { Box, CircularProgress, DialogContent, Stack, Typography } from '@mui/material' +import { useCallback, useState } from 'react' +import { useSubscription } from 'use-subscription' +import { CloseIcon } from '../assets/close' +import { context } from '../context' +import { usePersonas } from '../hooks/usePersonas' +import { useI18N } from '../locales/i18n_generated' +import type { TokenInfo } from '../types' +import { PersonaItem } from './PersonaItem' +import { InfoIcon } from '../assets/info' +import { useMyPersonas } from '../../../components/DataSource/useMyPersonas' + +const useStyles = makeStyles()((theme) => ({ + messageBox: { + display: 'flex', + borderRadius: 4, + padding: 8, + backgroundColor: theme.palette.mode === 'dark' ? '#15171A' : '#F9F9F9', + fontSize: 14, + alignItems: 'center', + color: theme.palette.text.primary, + }, +})) + +interface PersonaPageProps { + onNext: () => void + onClose(): void + onChange: (proof: BindingProof, wallets?: BindingProof[], tokenInfo?: TokenInfo) => void +} + +export function PersonaPage(props: PersonaPageProps) { + const { onNext, onChange, onClose } = props + const [visible, setVisible] = useState(true) + const currentIdentity = useSubscription(context.lastRecognizedProfile) + const { classes } = useStyles() + const { loading, value: persona } = usePersonas() + const myPersonas = useMyPersonas() + const t = useI18N() + + const onSelect = useCallback( + (proof: BindingProof, tokenInfo?: TokenInfo) => { + onChange(proof, persona?.wallets, tokenInfo) + onNext() + }, + [persona?.wallets], + ) + + return ( + + {loading ? ( + + + + ) : ( + <> + {visible ? ( + + + + {t.persona_hint()} + + setVisible(false)} /> + + ) : null} + {persona?.binds?.proofs + .filter((proof) => proof.platform === NextIDPlatform.Twitter) + .filter((x) => x.identity.toLowerCase() === currentIdentity?.identifier?.userId.toLowerCase()) + .map((x, i) => ( + + ))} + + {myPersonas?.[0] && + myPersonas[0].linkedProfiles + .filter((x) => x.identifier.network === currentIdentity?.identifier?.network) + .map((x, i) => + persona?.binds.proofs.some( + (y) => y.identity === x.identifier.userId.toLowerCase(), + ) ? null : ( + + ), + )} + {persona?.binds?.proofs + .filter((proof) => proof.platform === NextIDPlatform.Twitter) + .filter((x) => x.identity.toLowerCase() !== currentIdentity?.identifier?.userId.toLowerCase()) + .map((x, i) => ( + + ))} + + )} + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/UploadAvatarDialog.tsx b/packages/mask/src/plugins/Avatar/Application/UploadAvatarDialog.tsx new file mode 100644 index 000000000000..c310ea817d8e --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/UploadAvatarDialog.tsx @@ -0,0 +1,202 @@ +import { Button, DialogActions, DialogContent, Slider } from '@mui/material' +import AvatarEditor from 'react-avatar-editor' +import { makeStyles, useCustomSnackbar } from '@masknet/theme' +import { useCallback, useState } from 'react' +import { NextIDStorage, Twitter, TwitterBaseAPI } from '@masknet/web3-providers' +import type { ERC721TokenDetailed } from '@masknet/web3-shared-evm' +import { getAvatarId } from '../../../social-network-adaptor/twitter.com/utils/user' +import { usePersonaConnectStatus } from '../../../components/DataSource/usePersonaConnectStatus' +import { BindingProof, fromHex, PersonaIdentifier, ProfileIdentifier, toBase64 } from '@masknet/shared-base' +import type { NextIDAvatarMeta } from '../types' +import { useI18N } from '../locales/i18n_generated' +import { context } from '../context' +import { PluginNFTAvatarRPC } from '../messages' +import { PLUGIN_ID, RSS3_KEY_SNS } from '../constants' +import { useSubscription } from 'use-subscription' +import Services from '../../../extension/service' + +const useStyles = makeStyles()((theme) => ({ + actions: { + padding: theme.spacing(0, 2, 2, 2), + }, + cancel: { + backgroundColor: theme.palette.background.default, + color: theme.palette.mode === 'dark' ? '#FFFFFF' : '#111418', + border: 'none', + '&:hover': { + border: 'none', + }, + }, +})) + +interface UploadAvatarDialogProps { + account?: string + isBindAccount?: boolean + image?: string | File + token?: ERC721TokenDetailed + proof?: BindingProof + onBack: () => void + onClose: () => void +} + +type AvatarInfo = TwitterBaseAPI.AvatarInfo & { avatarId: string } + +async function saveToRSS3(info: NextIDAvatarMeta, account: string, identifier: ProfileIdentifier) { + const avatar = await PluginNFTAvatarRPC.saveNFTAvatar( + account, + info, + identifier.network, + RSS3_KEY_SNS.TWITTER, + ).catch((error) => { + console.log(error) + return + }) + return avatar +} + +async function saveToNextID(info: NextIDAvatarMeta, persona?: PersonaIdentifier, proof?: BindingProof) { + if (!proof?.identity || !persona?.publicKeyAsHex) return + const payload = await NextIDStorage.getPayload( + persona.publicKeyAsHex, + proof?.platform, + proof?.identity, + info, + PLUGIN_ID, + ) + if (!payload.ok) { + return + } + const result = await Services.Identity.generateSignResult(persona, payload.val.signPayload) + if (!result) return + const response = await NextIDStorage.set( + payload.val.uuid, + persona.publicKeyAsHex, + toBase64(fromHex(result.signature.signature)), + proof.platform, + proof.identity, + payload.val.createdAt, + info, + PLUGIN_ID, + ) + return response.ok +} + +async function Save( + account: string, + isBindAccount: boolean, + token: ERC721TokenDetailed, + data: AvatarInfo, + persona: PersonaIdentifier, + proof: BindingProof, + identifier: ProfileIdentifier, +) { + if (!data) return false + + const info: NextIDAvatarMeta = { + nickname: data.nickname, + userId: data.userId, + imageUrl: data.imageUrl, + avatarId: data.avatarId, + address: token.contractDetailed.address, + tokenId: token.tokenId, + chainId: token.contractDetailed.chainId, + } + + if (isBindAccount) { + return saveToNextID(info, persona, proof) + } + return saveToRSS3(info, account, identifier) +} + +async function uploadAvatar(blob: Blob, userId: string): Promise { + try { + const media = await Twitter.uploadUserAvatar(userId, blob) + const data = await Twitter.updateProfileImage(userId, media.media_id_string) + if (!data) { + return + } + const avatarId = getAvatarId(data?.imageUrl ?? '') + return { ...data, avatarId } + } catch (err) { + return + } +} +export function UploadAvatarDialog(props: UploadAvatarDialogProps) { + const { image, account, token, onClose, onBack, proof, isBindAccount = false } = props + const { classes } = useStyles() + const identifier = useSubscription(context.currentVisitingProfile) + const [editor, setEditor] = useState(null) + const [scale, setScale] = useState(1) + const { showSnackbar } = useCustomSnackbar() + const [disabled, setDisabled] = useState(false) + const { currentConnectedPersona } = usePersonaConnectStatus() + const t = useI18N() + + const onSave = useCallback(() => { + if (!editor) return + editor.getImage().toBlob(async (blob) => { + if (!blob || !account || !token || !currentConnectedPersona?.linkedProfiles[0].identifier || !proof) return + setDisabled(true) + + const avatarData = await uploadAvatar(blob, currentConnectedPersona?.linkedProfiles[0].identifier.userId) + if (!avatarData) { + setDisabled(false) + return + } + + const response = await Save( + account, + isBindAccount, + token, + avatarData, + currentConnectedPersona.identifier, + proof, + currentConnectedPersona.linkedProfiles[0].identifier, + ) + + if (!response) { + showSnackbar(t.upload_avatar_failed_message(), { variant: 'error' }) + setDisabled(false) + return + } + showSnackbar(t.upload_avatar_success_message(), { variant: 'success' }) + location.reload() + onClose() + setDisabled(false) + }) + }, [account, editor, identifier, onClose, currentConnectedPersona, proof, isBindAccount]) + + if (!account || !image || !token || !proof) return null + + return ( + <> + + setEditor(e)} + image={image!} + border={50} + style={{ width: '100%', height: '100%' }} + scale={scale ?? 1} + rotate={0} + borderRadius={300} + /> + setScale(value as number)} + aria-label="Scale" + /> + + + + + + + ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/WalletConnect.tsx b/packages/mask/src/plugins/Avatar/Application/WalletConnect.tsx new file mode 100644 index 000000000000..14f261fad9cd --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/WalletConnect.tsx @@ -0,0 +1,114 @@ +import { WalletMessages } from '@masknet/plugin-wallet' +import { useRemoteControlledDialog } from '@masknet/shared-base-ui' +import { makeStyles, useStylesExtends } from '@masknet/theme' +import { resolveProviderHomeLink, useProviderType } from '@masknet/web3-shared-evm' +import { Box, Button, Link, Typography } from '@mui/material' +import { WalletIcon } from '../assets/wallet' +import LaunchIcon from '@mui/icons-material/Launch' +import { useI18N } from '../locales/i18n_generated' +import type { HTMLProps } from 'react' +import { ApplicationSmallIcon } from '../assets/application' +import { NetworkPluginID } from '@masknet/plugin-infra/web3' + +const useStyles = makeStyles()((theme) => ({ + root: { + background: + 'linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%), linear-gradient(90deg, rgba(28, 104, 243, 0.2) 0%, rgba(249, 55, 55, 0.2) 100%), #FFFFFF', + borderRadius: 16, + padding: 14, + position: 'relative', + height: 196, + margin: theme.spacing(2), + }, + title: { + display: 'flex', + color: '#07101b', + }, + button: { + textAlign: 'center', + alignItems: 'center', + position: 'absolute', + bottom: 16, + width: '100%', + }, + link: { + padding: 0, + marginLeft: theme.spacing(0.5), + marginTop: 2, + color: '#6E767D', + }, + rectangle: { + marginLeft: theme.spacing(1), + marginTop: theme.spacing(6), + }, +})) + +interface NFTWalletConnectProps extends withClasses<'root'> {} + +export function NFTWalletConnect(props: NFTWalletConnectProps) { + const classes = useStylesExtends(useStyles(), props) + const providerType = useProviderType() + const t = useI18N() + + const { setDialog: openSelectProviderDialog } = useRemoteControlledDialog( + WalletMessages.events.selectProviderDialogUpdated, + ) + return ( + + + + + {t.application_dialog_title()} + + + + {t.provider_by()} + + + Mask Network + + + + + + + + + + + ) +} + +const useRectangleStyles = makeStyles()(() => ({ + rectangle: { + height: 8, + background: 'rgba(255, 255, 255, 0.5)', + borderRadius: 8, + }, +})) +interface RectangleProps extends HTMLProps {} + +export function Rectangle(props: RectangleProps) { + const { classes } = useRectangleStyles() + return ( +
+
+
+
+
+ ) +} diff --git a/packages/mask/src/plugins/Avatar/Application/WalletList.tsx b/packages/mask/src/plugins/Avatar/Application/WalletList.tsx new file mode 100644 index 000000000000..aebe972e725b --- /dev/null +++ b/packages/mask/src/plugins/Avatar/Application/WalletList.tsx @@ -0,0 +1,331 @@ +import { ImageIcon, useSnackbarCallback } from '@masknet/shared' +import { makeStyles, MaskColorVar, ShadowRootMenu, ShadowRootTooltip, useStylesExtends } from '@masknet/theme' +import { isSameAddress } from '@masknet/web3-shared-evm' +import { Button, Divider, IconProps, Link, ListItemIcon, MenuItem, Stack, Typography, useTheme } from '@mui/material' +import { memo, useCallback, useEffect, useState } from 'react' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import { WalletSettingIcon } from '../assets/setting' +import { CheckedIcon, UncheckIcon } from '../assets/checked' +import { useRemoteControlledDialog } from '@masknet/shared-base-ui' +import { WalletMessages } from '@masknet/plugin-wallet' +import { NFTWalletConnect } from './WalletConnect' +import { BindingProof, PopupRoutes } from '@masknet/shared-base' +import { + NetworkPluginID, + useChainId, + useCurrentWeb3NetworkPluginID, + useNetworkDescriptor, + useReverseAddress, + useWeb3State, +} from '@masknet/plugin-infra/web3' +import { Services } from '../../../extension/service' +import { useI18N } from '../locales/i18n_generated' +import { useCopyToClipboard } from 'react-use' +import { LinkIcon } from '../assets/link' +import { CopyIcon } from '../assets/copy' +import classNames from 'classnames' +import { VerifyIcon } from '../assets/verify' +import { noop } from 'lodash-unified' +import { Verify2Icon } from '../assets/Verify2' +import { formatAddress } from '../utils' + +const useStyles = makeStyles()((theme) => ({ + root: { + borderRadius: 9999, + paddingLeft: 4, + paddingRight: 4, + cursor: 'pointer', + backgroundColor: theme.palette.mode === 'dark' ? '#15171A' : '#F6F8F8', + }, + wrapper: {}, + address: { + lineHeight: 1.5, + }, + copy: { + color: theme.palette.secondary.main, + }, + + icon: { + width: 24, + height: 24, + }, + iconShadow: { + filter: 'drop-shadow(0px 0px 6px rgba(28, 104, 243, 0.6))', + }, + change: { + marginLeft: theme.spacing(4), + backgroundColor: MaskColorVar.twitterButton, + borderRadius: 9999, + fontWeight: 600, + fontSize: 14, + }, + divider: { + borderColor: theme.palette.mode === 'dark' ? '#2F3336' : '#F2F5F6', + marginLeft: 16, + marginRight: 16, + }, + paper: { + backgroundColor: 'black', + width: 335, + }, +})) + +interface AddressNamesProps extends withClasses<'root'> { + onChange: (address: string) => void + account: string + wallets: BindingProof[] +} + +export function AddressNames(props: AddressNamesProps) { + const { onChange, account, wallets } = props + const classes = useStylesExtends(useStyles(), props) + const [anchorEl, setAnchorEl] = useState(null) + const onClose = () => setAnchorEl(null) + const onOpen = (event: React.MouseEvent) => setAnchorEl(event.currentTarget) + const t = useI18N() + const currentPluginId = useCurrentWeb3NetworkPluginID() + + const [selectedWallet, setSelectedWallet] = useState(account || wallets?.[0]?.identity || '') + const onClick = useCallback((address: string) => { + onChange(address) + setSelectedWallet(address) + onClose() + }, []) + + const theme = useTheme() + + useEffect(() => { + if (!account && !wallets?.[0]?.identity) return + setSelectedWallet(account || wallets?.[0]?.identity) + }, [account, wallets?.[0]?.identity]) + + const { setDialog: openSelectProviderDialog } = useRemoteControlledDialog( + WalletMessages.events.selectProviderDialogUpdated, + ) + const chainId = useChainId() + const openPopupsWindow = useCallback(() => { + Services.Helper.openPopupWindow(PopupRoutes.ConnectedWallets, { + chainId, + internal: true, + }) + }, [chainId]) + + const onConnectWallet = useCallback(() => { + openSelectProviderDialog({ open: true, pluginID: NetworkPluginID.PLUGIN_EVM }) + onClose() + }, [openSelectProviderDialog, onClose]) + + const walletItem = ( + selectedWallet: string, + wallet: string, + enableChange: boolean, + onClick: (wallet: string) => void, + onChange?: () => void, + verify?: boolean, + isETH?: boolean, + ) => ( + onClick(account)}> + + {selectedWallet === wallet ? ( + <> + + + ) : ( + + )} + + + {enableChange && ( + + )} + + ) + + if (!wallets.length && (currentPluginId !== NetworkPluginID.PLUGIN_EVM || !account)) return + + return ( + + + isSameAddress(x.identity, account)) + ? true + : currentPluginId === NetworkPluginID.PLUGIN_EVM + } + /> + + + + {account ? ( + walletItem( + selectedWallet, + account, + Boolean(account), + () => onClick(account), + onConnectWallet, + wallets.some((x) => isSameAddress(x.identity, account)), + wallets.some((x) => isSameAddress(x.identity, account)) + ? true + : currentPluginId === NetworkPluginID.PLUGIN_EVM, + ) + ) : ( + + + + )} + + {wallets + .sort((a, b) => Number.parseInt(b.created_at, 10) - Number.parseInt(a.created_at, 10)) + ?.filter((x) => !isSameAddress(x.identity, account)) + .map((x) => ( + <> + {walletItem( + selectedWallet, + x.identity, + false, + () => onClick(x.identity), + () => noop, + true, + true, + )} + + + ))} + + { + openPopupsWindow() + onClose() + }}> + + + + + {t.wallet_settings()} + + + + + + ) +} + +const useWalletUIStyles = makeStyles()((theme) => ({ + root: {}, + address: { + fontSize: 10, + }, + copy: { + fontSize: 16, + cursor: 'pointer', + }, + link: { + color: theme.palette.text.secondary, + lineHeight: 0, + }, + linkIcon: { + width: 16, + height: 16, + }, + walletName: { + color: theme.palette.mode === 'dark' ? '#D9D9D9' : '#0F1419', + }, + walletAddress: { + color: theme.palette.mode === 'dark' ? '#6E767D' : '#536471', + }, +})) + +interface WalletUIProps { + address: string + verify?: boolean + isETH?: boolean +} + +function WalletUI(props: WalletUIProps) { + const { Utils } = useWeb3State() + const { isETH, address, verify = false } = props + + const { classes } = useWalletUIStyles() + const chainId = useChainId() + const currentPluginId = useCurrentWeb3NetworkPluginID() + const networkDescriptor = useNetworkDescriptor(chainId, isETH ? NetworkPluginID.PLUGIN_EVM : currentPluginId) + const { value: domain } = useReverseAddress(address, NetworkPluginID.PLUGIN_EVM) + if (!address) return null + return ( + + + + + + {currentPluginId === NetworkPluginID.PLUGIN_EVM + ? domain ?? formatAddress(address, 4) + : formatAddress(address, 4)} + + {verify ? : null} + + + + {formatAddress(address, 4)} + + + + + + + + + ) +} + +interface CopyIconButtonProps extends IconProps { + text: string +} +const CopyIconButton = memo(({ text, ...props }) => { + const t = useI18N() + const theme = useTheme() + const [, copyToClipboard] = useCopyToClipboard() + const [open, setOpen] = useState(false) + + const onCopy = useSnackbarCallback({ + executor: async () => copyToClipboard(text), + deps: [], + successText: t.copy_success_of_wallet_address(), + }) + + return ( + {t.copied()}} + open={open} + onMouseLeave={() => setOpen(false)} + disableFocusListener + disableTouchListener> + + + ) +}) diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/AddNFT.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/AddNFT.tsx index 97c7c707fe60..2dc642f172f3 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/AddNFT.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/AddNFT.tsx @@ -1,11 +1,11 @@ import { makeStyles } from '@masknet/theme' -import { ERC721TokenDetailed, isSameAddress, useAccount } from '@masknet/web3-shared-evm' -import { Button, DialogContent, Typography } from '@mui/material' +import { ChainId, ERC721TokenDetailed, isSameAddress, useAccount } from '@masknet/web3-shared-evm' +import { Button, DialogContent, InputBase, Typography } from '@mui/material' import { useCallback, useState } from 'react' import { InjectedDialog } from '@masknet/shared' -import { InputBox } from '../../../extension/options-page/DashboardComponents/InputBox' import { useI18N } from '../../../utils' import { createNFT } from '../utils' +import { WalletRPC } from '../../Wallet/messages' const useStyles = makeStyles()((theme) => ({ root: {}, @@ -17,6 +17,13 @@ const useStyles = makeStyles()((theme) => ({ input: { marginTop: theme.spacing(1), marginBottom: theme.spacing(1), + display: 'block', + width: '100%', + border: `1px solid ${theme.palette.mode === 'dark' ? '#2F3336' : '#EFF3F4'}`, + alignItems: 'center', + padding: theme.spacing(1), + boxSizing: 'border-box', + borderRadius: 8, }, message: { '&:before': { @@ -27,9 +34,12 @@ const useStyles = makeStyles()((theme) => ({ }, })) export interface AddNFTProps { + account?: string onClose: () => void - onAddClick: (token: ERC721TokenDetailed) => void + chainId?: ChainId + onAddClick?: (token: ERC721TokenDetailed) => void open: boolean + title?: string } export function AddNFT(props: AddNFTProps) { const { t } = useI18N() @@ -37,8 +47,8 @@ export function AddNFT(props: AddNFTProps) { const [address, setAddress] = useState('') const [tokenId, setTokenId] = useState('') const [message, setMessage] = useState('') - const { onClose, open, onAddClick } = props - const account = useAccount() + const { onClose, open, onAddClick, title, chainId, account } = props + const _account = useAccount() const onClick = useCallback(async () => { if (!address) { @@ -50,14 +60,18 @@ export function AddNFT(props: AddNFTProps) { return } - createNFT(address, tokenId) - .then((token) => { - if (!token || !isSameAddress(token?.info.owner, account)) { + createNFT(address, tokenId, chainId) + .then(async (token) => { + if (chainId && token && token.contractDetailed.chainId !== chainId) { + setMessage('chain does not match.') + return + } + if (!token || !isSameAddress(token?.info.owner, account ?? _account)) { setMessage(t('nft_owner_hint')) return } - - onAddClick(token) + await WalletRPC.addToken(token) + onAddClick?.(token) handleClose() }) .catch((error) => setMessage(t('nft_owner_hint'))) @@ -78,19 +92,31 @@ export function AddNFT(props: AddNFTProps) { } return ( - +
- onAddressChange(address)} /> + onAddressChange(e.target.value)} + />
- onTokenIdChange(tokenId)} /> + onTokenIdChange(e.target.value)} + />
{message ? ( - + {message} ) : null} diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx index 93a23fbbab07..17c4115fe58f 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx @@ -2,6 +2,7 @@ import { GearSettingsIcon } from '@masknet/icons' import { makeStyles, useStylesExtends } from '@masknet/theme' import { Typography } from '@mui/material' import { useI18N } from '../../../utils' +import { ApplicationSmallIcon } from '../assets/application' const useStyles = makeStyles()((theme) => ({ root: { @@ -10,11 +11,10 @@ const useStyles = makeStyles()((theme) => ({ justifyContent: 'center', alignItems: 'center', borderRadius: 9999, - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), - border: '1px solid', - backgroundColor: theme.palette.mode === 'dark' ? 'rgb(255, 255, 255)' : 'rgb(0, 0, 0)', - color: theme.palette.mode === 'dark' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)', + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + border: `1px solid ${theme.palette.mode === 'dark' ? '#2F3336' : '#EFF3F4'}`, + color: theme.palette.text.primary, cursor: 'pointer', }, icon: { @@ -25,10 +25,11 @@ const useStyles = makeStyles()((theme) => ({ text: { display: 'flex', alignItems: 'center', + marginLeft: 4, }, })) -interface NFTAvatarButtonProps extends withClasses<'root'> { +interface NFTAvatarButtonProps extends withClasses<'root' | 'text'> { onClick: () => void showSetting?: boolean } @@ -40,9 +41,9 @@ export function NFTAvatarButton(props: NFTAvatarButtonProps) { return (
+ - 🔥 - {t('nft_avatar')} + {t('nft_avatar')} {props.showSetting ? : null}
diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx index 42a0d94f1b07..fc1cb595c64b 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx @@ -34,6 +34,7 @@ export function NFTBadge(props: NFTBadgeProps) { const { value = { amount: '0', symbol: 'ETH', name: '', slug: '' }, loading } = useNFT( avatar.address, avatar.tokenId, + avatar.chainId, ) const { amount, symbol, name, slug } = value @@ -45,7 +46,10 @@ export function NFTBadge(props: NFTBadgeProps) { e.preventDefault() openWindow(resolveOpenSeaLink(avatar.address, avatar.tokenId)) }}> - + { userId: string @@ -23,7 +23,7 @@ const useStyles = makeStyles()(() => ({ export function NFTBadgeTimeline(props: NFTBadgeTimelineProps) { const { userId, avatarId, width, height, snsKey } = props - const { loading, value: _avatar } = useNFTAvatar(userId, snsKey) + const { loading, value: _avatar } = usePersonaNFTAvatar(userId, avatarId, snsKey) const [avatar, setAvatar] = useState() const [avatarId_, setAvatarId_] = useState('') const classes = useStylesExtends(useStyles(), props) diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTImage.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTImage.tsx index efd80e29ec7f..5a019eb2032c 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTImage.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTImage.tsx @@ -1,11 +1,12 @@ import { makeStyles } from '@masknet/theme' import { ERC721TokenDetailed, isSameAddress } from '@masknet/web3-shared-evm' import classNames from 'classnames' +import { SelectedIcon } from '../assets/selected' const useStyles = makeStyles()((theme) => ({ imgBackground: { position: 'relative', - margin: theme.spacing(0.5, 1), + margin: theme.spacing(1.25, 1, 1.25, 1.5), borderRadius: '100%', display: 'flex', alignItems: 'center', @@ -13,10 +14,11 @@ const useStyles = makeStyles()((theme) => ({ }, icon: { position: 'absolute', - top: 5, - right: 5, - width: 24, - height: 24, + top: 2, + right: 2, + width: 20, + height: 20, + color: theme.palette.primary.main, }, image: { width: 97, @@ -25,11 +27,11 @@ const useStyles = makeStyles()((theme) => ({ borderRadius: '100%', boxSizing: 'border-box', '&:hover': { - border: `4px solid ${theme.palette.primary.main}`, + border: `1px solid ${theme.palette.primary.main}`, }, }, selected: { - border: `4px solid ${theme.palette.primary.main}`, + border: `1px solid ${theme.palette.primary.main}`, }, })) @@ -37,6 +39,7 @@ interface NFTImageProps { token: ERC721TokenDetailed selectedToken?: ERC721TokenDetailed onChange: (token: ERC721TokenDetailed) => void + showBadge?: boolean } function isSameNFT(a: ERC721TokenDetailed, b?: ERC721TokenDetailed) { @@ -48,7 +51,7 @@ function isSameNFT(a: ERC721TokenDetailed, b?: ERC721TokenDetailed) { } export function NFTImage(props: NFTImageProps) { - const { token, onChange, selectedToken } = props + const { token, onChange, selectedToken, showBadge = false } = props const { classes } = useStyles() return ( @@ -58,6 +61,7 @@ export function NFTImage(props: NFTImageProps) { src={token.info.imageURL} className={classNames(classes.image, isSameNFT(token, selectedToken) ? classes.selected : '')} /> + {showBadge && isSameNFT(token, selectedToken) ? : null}
) } diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/background/amount.png b/packages/mask/src/plugins/Avatar/SNSAdaptor/background/amount.png deleted file mode 100644 index 9df02cb6ee4a..000000000000 Binary files a/packages/mask/src/plugins/Avatar/SNSAdaptor/background/amount.png and /dev/null differ diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/background/noamount.png b/packages/mask/src/plugins/Avatar/SNSAdaptor/background/noamount.png deleted file mode 100644 index 175fcf651e94..000000000000 Binary files a/packages/mask/src/plugins/Avatar/SNSAdaptor/background/noamount.png and /dev/null differ diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/background/noname.png b/packages/mask/src/plugins/Avatar/SNSAdaptor/background/noname.png deleted file mode 100644 index e39d479cb745..000000000000 Binary files a/packages/mask/src/plugins/Avatar/SNSAdaptor/background/noname.png and /dev/null differ diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/index.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/index.tsx index cbabb7a159eb..2f43bfb07606 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/index.tsx @@ -1,9 +1,56 @@ -import type { Plugin } from '@masknet/plugin-infra' +import type { Plugin } from '@masknet/plugin-infra/content-script' +import { ApplicationEntry } from '@masknet/shared' +import { useState } from 'react' +import { NFTAvatarDialog } from '../Application/NFTAvatarsDialog' import { base } from '../base' +import { setupContext } from '../context' +import { Trans } from 'react-i18next' +import { PluginI18NFieldRender } from '@masknet/plugin-infra/content-script' +import { ApplicationIcon } from '../assets/application' const sns: Plugin.SNSAdaptor.Definition = { ...base, - init(signal) {}, + init(signal, context) { + setupContext(context) + }, + ApplicationEntries: [ + (() => { + const name = { fallback: 'NFT PFP' } + const icon = + const recommendFeature = { + description: , + backgroundGradient: 'linear-gradient(360deg, #FFECD2 -0.43%, #FCB69F 99.57%)', + } + return { + RenderEntryComponent(EntryComponentProps) { + const [open, setOpen] = useState(false) + return ( + <> + } + icon={icon} + recommendFeature={recommendFeature} + {...EntryComponentProps} + onClick={EntryComponentProps.onClick ?? (() => setOpen(true))} + tooltipHint={ + EntryComponentProps.tooltipHint ?? + (EntryComponentProps.disabled ? undefined : ) + } + /> + + setOpen(false)} /> + + ) + }, + appBoardSortingDefaultPriority: 3, + name, + icon, + ApplicationEntryID: base.ID, + nextIdRequired: true, + recommendFeature, + } + })(), + ], } export default sns diff --git a/packages/mask/src/plugins/Avatar/assets/Verify2.tsx b/packages/mask/src/plugins/Avatar/assets/Verify2.tsx new file mode 100644 index 000000000000..32b015145de2 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/Verify2.tsx @@ -0,0 +1,19 @@ +import { createIcon } from '@masknet/icons' +export const Verify2Icon = createIcon( + 'Verify2Icon', + + + + , + '0 0 20 20', +) diff --git a/packages/mask/src/plugins/Avatar/assets/application.tsx b/packages/mask/src/plugins/Avatar/assets/application.tsx new file mode 100644 index 000000000000..7dc9378ccd8a --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/application.tsx @@ -0,0 +1,63 @@ +import { createIcon } from '@masknet/icons' + +export const ApplicationIcon = createIcon( + 'ApplicationIcon', + + + + + + + + + + , + '0 0 24 24', +) + +export const ApplicationRoundIcon = createIcon( + 'ApplicationRoundIcon', + + + + + + + + + + , + '0 0 24 25', +) + +export const ApplicationSmallIcon = createIcon( + 'ApplicationSmallIcon', + + + , + '0 0 21 20', +) diff --git a/packages/mask/src/plugins/Avatar/assets/checked.tsx b/packages/mask/src/plugins/Avatar/assets/checked.tsx new file mode 100644 index 000000000000..4b3ce7be9630 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/checked.tsx @@ -0,0 +1,25 @@ +import { createIcon } from '@masknet/icons' + +export const CheckedIcon = createIcon( + 'CheckedIcon', + + + + , + '0 0 24 24', +) + +export const UncheckIcon = createIcon( + 'UncheckIcon', + + + , + '0 0 24 24', +) diff --git a/packages/mask/src/plugins/Avatar/assets/close.tsx b/packages/mask/src/plugins/Avatar/assets/close.tsx new file mode 100644 index 000000000000..cc9f93529008 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/close.tsx @@ -0,0 +1,12 @@ +import { createIcon } from '@masknet/icons' + +export const CloseIcon = createIcon( + 'CloseIcon', + + + , + '0 0 20 20', +) diff --git a/packages/mask/src/plugins/Avatar/assets/copy.tsx b/packages/mask/src/plugins/Avatar/assets/copy.tsx new file mode 100644 index 000000000000..bf8033061abf --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/copy.tsx @@ -0,0 +1,20 @@ +import { createIcon } from '@masknet/icons' + +export const CopyIcon = createIcon( + 'CopyIcon', + + + + , + '0 0 12 12', +) diff --git a/packages/mask/src/plugins/Avatar/assets/frame.tsx b/packages/mask/src/plugins/Avatar/assets/frame.tsx new file mode 100644 index 000000000000..7ea6f3306244 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/frame.tsx @@ -0,0 +1,44 @@ +import { SvgIcon, SvgIconProps } from '@mui/material' + +const svg = ( + + + + + + + + + + + + + + + + + + + +) + +export const FrameIcon = (props: SvgIconProps) => {svg} diff --git a/packages/mask/src/plugins/Avatar/assets/info.tsx b/packages/mask/src/plugins/Avatar/assets/info.tsx new file mode 100644 index 000000000000..d37e4a7b6f4f --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/info.tsx @@ -0,0 +1,26 @@ +import { createIcon } from '@masknet/icons' + +export const InfoIcon = createIcon( + 'InfoIcon', + + + + + , + '0 0 20 21', +) diff --git a/packages/mask/src/plugins/Avatar/assets/link.tsx b/packages/mask/src/plugins/Avatar/assets/link.tsx new file mode 100644 index 000000000000..80e7df2ab6ea --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/link.tsx @@ -0,0 +1,14 @@ +import { createIcon } from '@masknet/icons' + +export const LinkIcon = createIcon( + 'LinkIcon', + + + , + '0 0 16 16', +) diff --git a/packages/mask/src/plugins/Avatar/assets/more.tsx b/packages/mask/src/plugins/Avatar/assets/more.tsx new file mode 100644 index 000000000000..bae220e80a16 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/more.tsx @@ -0,0 +1,14 @@ +import { SvgIcon, SvgIconProps } from '@mui/material' + +const svg = ( + + + +) + +export const MoreIcon = (props: SvgIconProps) => {svg} diff --git a/packages/mask/src/plugins/Avatar/assets/nftavatar.png b/packages/mask/src/plugins/Avatar/assets/nftavatar.png new file mode 100644 index 000000000000..8ac22a7c4f2f Binary files /dev/null and b/packages/mask/src/plugins/Avatar/assets/nftavatar.png differ diff --git a/packages/mask/src/plugins/Avatar/assets/point.tsx b/packages/mask/src/plugins/Avatar/assets/point.tsx new file mode 100644 index 000000000000..79dc37bd92df --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/point.tsx @@ -0,0 +1,12 @@ +import { createIcon } from '@masknet/icons' +export const PointIcon = createIcon( + 'PointIcon', + + + , + '0 0 9 9', +) diff --git a/packages/mask/src/plugins/Avatar/assets/selected.tsx b/packages/mask/src/plugins/Avatar/assets/selected.tsx new file mode 100644 index 000000000000..d6576f1d8a41 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/selected.tsx @@ -0,0 +1,16 @@ +import { createIcon } from '@masknet/icons' +export const SelectedIcon = createIcon( + 'SelectedIcon', + + + + , + '0 0 24 24', +) diff --git a/packages/mask/src/plugins/Avatar/assets/setting.tsx b/packages/mask/src/plugins/Avatar/assets/setting.tsx new file mode 100644 index 000000000000..25593367bade --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/setting.tsx @@ -0,0 +1,16 @@ +import { createIcon } from '@masknet/icons' + +export const WalletSettingIcon = createIcon( + 'WalletSettingIcon', + + + + , + '0 0 20 20', +) diff --git a/packages/mask/src/plugins/Avatar/assets/twitter.tsx b/packages/mask/src/plugins/Avatar/assets/twitter.tsx new file mode 100644 index 000000000000..a77f6b2beb40 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/twitter.tsx @@ -0,0 +1,20 @@ +import { createIcon } from '@masknet/icons' + +export const TwitterIcon = createIcon( + 'TwitterIcon', + + + + + , + '0 0 15 15', +) diff --git a/packages/mask/src/plugins/Avatar/assets/verify.tsx b/packages/mask/src/plugins/Avatar/assets/verify.tsx new file mode 100644 index 000000000000..d0987974f62d --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/verify.tsx @@ -0,0 +1,17 @@ +import { createIcon } from '@masknet/icons' +export const VerifyIcon = createIcon( + 'VerifyIcon', + + + + , + '0 0 16 16', +) diff --git a/packages/mask/src/plugins/Avatar/assets/wallet.tsx b/packages/mask/src/plugins/Avatar/assets/wallet.tsx new file mode 100644 index 000000000000..181d745050a2 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/assets/wallet.tsx @@ -0,0 +1,32 @@ +import { createIcon } from '@masknet/icons' + +export const WalletIcon = createIcon( + 'WalletIcon', + + + + + + , + '0 0 18 18', +) diff --git a/packages/mask/src/plugins/Avatar/base.ts b/packages/mask/src/plugins/Avatar/base.ts index 68359a709d4f..f75751d6eb86 100644 --- a/packages/mask/src/plugins/Avatar/base.ts +++ b/packages/mask/src/plugins/Avatar/base.ts @@ -1,5 +1,6 @@ import { PLUGIN_ID } from './constants' -import { CurrentSNSNetwork, Plugin } from '@masknet/plugin-infra' +import { languages } from './locales/languages' +import { Plugin, CurrentSNSNetwork } from '@masknet/plugin-infra' export const base: Plugin.Shared.Definition = { ID: PLUGIN_ID, @@ -14,10 +15,12 @@ export const base: Plugin.Shared.Definition = { type: 'opt-in', networks: { [CurrentSNSNetwork.Twitter]: true, - [CurrentSNSNetwork.Facebook]: true, - [CurrentSNSNetwork.Instagram]: true, + [CurrentSNSNetwork.Facebook]: false, + [CurrentSNSNetwork.Instagram]: false, }, }, target: 'stable', }, + + i18n: languages, } diff --git a/packages/mask/src/plugins/Avatar/constants.ts b/packages/mask/src/plugins/Avatar/constants.ts index 7d715a838706..952ee5b534ea 100644 --- a/packages/mask/src/plugins/Avatar/constants.ts +++ b/packages/mask/src/plugins/Avatar/constants.ts @@ -1,10 +1,10 @@ import { PluginId } from '@masknet/plugin-infra' +import { ChainId } from '@masknet/web3-shared-evm' export const NFT_AVATAR_JSON_SERVER = 'https://configuration.r2d2.to/com.maskbook.avatar.json' export const NFT_AVATAR_DB_NAME = 'com.maskbook.user' export const NFT_AVATAR_DB_NAME_STORAGE = 'com.maskbook.user.storage' -export const NFT_CONTRACT_JSON_VERIFIED_SERVER = 'https://configuration.r2d2.to/com.maskbook.verified_nft.json' export const PLUGIN_ID = PluginId.Avatar export const PLUGIN_NAME = 'Avatar' export const PLUGIN_DESCRIPTION = 'NFT Avatar' @@ -14,3 +14,10 @@ export enum RSS3_KEY_SNS { FACEBOOK = '_facebook_nfts', INSTAGRAM = '_instagram_nfts', } + +export enum Application_NFT_LIST_PAGE { + Application_nft_tab_eth_page = 'ETH', + Application_nft_tab_polygon_page = 'Polygon', +} + +export const SUPPORTED_CHAIN_IDS: ChainId[] = [ChainId.Mainnet, ChainId.Matic] diff --git a/packages/mask/src/plugins/Avatar/context.ts b/packages/mask/src/plugins/Avatar/context.ts new file mode 100644 index 000000000000..e6904a0c28f5 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/context.ts @@ -0,0 +1,5 @@ +import type { Plugin } from '@masknet/plugin-infra/content-script' +export let context: Plugin.SNSAdaptor.SNSAdaptorContext +export function setupContext(x: typeof context) { + context = x +} diff --git a/packages/mask/src/plugins/Avatar/hooks/useNFT.ts b/packages/mask/src/plugins/Avatar/hooks/useNFT.ts index c2be4ebcedc1..5e3c0344e83b 100644 --- a/packages/mask/src/plugins/Avatar/hooks/useNFT.ts +++ b/packages/mask/src/plugins/Avatar/hooks/useNFT.ts @@ -1,16 +1,32 @@ +import { ChainId } from '@masknet/web3-shared-evm' import { useAsyncRetry } from 'react-use' import type { NFT } from '../types' -import { getNFT } from '../utils' +import { getNFT, getNFTByChain } from '../utils' const NFTCache = new Map>() -export function useNFT(address: string, tokenId: string) { +export function useNFT(address: string, tokenId: string, chainId?: ChainId) { return useAsyncRetry(async () => { if (!address || !tokenId) return - let f = NFTCache.get(`${address}-${tokenId}`) + let f = NFTCache.get(`${address}-${tokenId}-${chainId ?? ChainId.Mainnet}`) if (!f) { - f = getNFT(address, tokenId) - NFTCache.set(`${address}-${tokenId}`, f) + f = _getNFT(address, tokenId, chainId ?? ChainId.Mainnet) + NFTCache.set(`${address}-${tokenId}-${chainId ?? ChainId.Mainnet}`, f) } return f - }, [address, tokenId, NFTCache, getNFT]) + }, [address, tokenId, NFTCache, getNFT, chainId]) +} + +async function _getNFT(address: string, tokenId: string, chainId: ChainId) { + if (chainId === ChainId.Mainnet) { + return getNFT(address, tokenId) + } + const nft = await getNFTByChain(address, tokenId, chainId) + return { + amount: '0', + name: nft?.contractDetailed.name ?? '', + symbol: nft?.contractDetailed.symbol ?? 'ETH', + image: nft?.info.imageURL ?? '', + owner: nft?.info.owner ?? '', + slug: '', + } } diff --git a/packages/mask/src/plugins/Avatar/hooks/usePersonaNFTAvatar.ts b/packages/mask/src/plugins/Avatar/hooks/usePersonaNFTAvatar.ts new file mode 100644 index 000000000000..73db8002948a --- /dev/null +++ b/packages/mask/src/plugins/Avatar/hooks/usePersonaNFTAvatar.ts @@ -0,0 +1,22 @@ +import { useAsyncRetry } from 'react-use' +import { activatedSocialNetworkUI } from '../../../social-network' +import type { RSS3_KEY_SNS } from '../constants' +import { PluginNFTAvatarRPC } from '../messages' +import { getNFTAvatarByUserId } from '../utils' + +export function usePersonaNFTAvatar(userId: string, avatarId: string, snsKey: RSS3_KEY_SNS) { + return useAsyncRetry(async () => { + const avatarMetaFromPersona = await getNFTAvatarByUserId(userId, avatarId) + if (avatarMetaFromPersona) return avatarMetaFromPersona + + const avatarMeta = await PluginNFTAvatarRPC.getNFTAvatar( + userId, + activatedSocialNetworkUI.networkIdentifier, + snsKey, + ) + + if (!avatarMeta) return + + return { ...avatarMeta, imageUrl: '', nickname: '' } + }, [userId]) +} diff --git a/packages/mask/src/plugins/Avatar/hooks/usePersonaVerified.ts b/packages/mask/src/plugins/Avatar/hooks/usePersonaVerified.ts new file mode 100644 index 000000000000..2df5604f5e0f --- /dev/null +++ b/packages/mask/src/plugins/Avatar/hooks/usePersonaVerified.ts @@ -0,0 +1,23 @@ +import type { NextIDPlatform } from '@masknet/shared-base' +import { NextIDProof } from '@masknet/web3-providers' +import { useAsyncRetry } from 'react-use' +import { useSubscription } from 'use-subscription' +import Services from '../../../extension/service' +import { activatedSocialNetworkUI } from '../../../social-network' +import { context } from '../context' + +export function usePersonaVerify() { + const platform = activatedSocialNetworkUI.configuration.nextIDConfig?.platform as NextIDPlatform + const visitingPersonaIdentifier = useSubscription(context.lastRecognizedProfile) + return useAsyncRetry(async () => { + if (!visitingPersonaIdentifier?.identifier) return + const persona = await Services.Identity.queryPersonaByProfile(visitingPersonaIdentifier.identifier) + if (!persona?.identifier.publicKeyAsHex) return + const isVerified = await NextIDProof.queryIsBound( + persona.identifier.publicKeyAsHex, + platform, + visitingPersonaIdentifier.identifier.userId, + ) + return { isVerified } + }, [platform, visitingPersonaIdentifier]) +} diff --git a/packages/mask/src/plugins/Avatar/hooks/usePersonas.ts b/packages/mask/src/plugins/Avatar/hooks/usePersonas.ts new file mode 100644 index 000000000000..c44a73ddc0b0 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/hooks/usePersonas.ts @@ -0,0 +1,38 @@ +import { NextIDPlatform } from '@masknet/shared-base' +import { NextIDProof } from '@masknet/web3-providers' +import { first } from 'lodash-unified' +import { useAsyncRetry } from 'react-use' +import { useSubscription } from 'use-subscription' +import { usePersonaConnectStatus } from '../../../components/DataSource/usePersonaConnectStatus' +import { activatedSocialNetworkUI } from '../../../social-network' +import { context } from '../context' +import { sortPersonaBindings } from '../utils' + +export function usePersonas(userId?: string) { + const personaConnectStatus = usePersonaConnectStatus() + const currentIdentifier = useSubscription(context.currentVisitingProfile) + const platform = activatedSocialNetworkUI.configuration.nextIDConfig?.platform as NextIDPlatform + const identifier = useSubscription(context.lastRecognizedProfile) + return useAsyncRetry(async () => { + if (!identifier?.identifier?.userId) return + const personaBindings = await NextIDProof.queryExistedBindingByPlatform( + platform, + userId?.toLowerCase() ?? identifier.identifier.userId.toLowerCase(), + ) + + const currentPersonaBinding = first( + personaBindings.sort((a, b) => + sortPersonaBindings(a, b, userId?.toLowerCase() ?? identifier.identifier?.userId.toLowerCase() ?? ''), + ), + ) + if (!currentPersonaBinding) return + + const isOwner = userId + ? currentIdentifier?.identifier?.toText() === userId.toLowerCase() + : currentIdentifier?.identifier && + identifier.identifier && + currentIdentifier?.identifier.toText() === identifier.identifier.toText() + const wallets = currentPersonaBinding?.proofs.filter((proof) => proof.platform === NextIDPlatform.Ethereum) + return { wallets, isOwner, binds: currentPersonaBinding, status: personaConnectStatus } + }, [currentIdentifier, identifier, personaConnectStatus.hasPersona, platform, userId]) +} diff --git a/packages/mask/src/plugins/Avatar/hooks/useTokenOwner.ts b/packages/mask/src/plugins/Avatar/hooks/useTokenOwner.ts new file mode 100644 index 000000000000..878dd96181cd --- /dev/null +++ b/packages/mask/src/plugins/Avatar/hooks/useTokenOwner.ts @@ -0,0 +1,45 @@ +import { + ChainId, + isSameAddress, + safeNonPayableTransactionCall, + useAccount, + useERC721TokenContract, +} from '@masknet/web3-shared-evm' +import { useAsyncRetry } from 'react-use' +import { activatedSocialNetworkUI } from '../../../social-network' +import { PluginNFTAvatarRPC } from '../messages' +import { getNFTByOpensea } from '../utils' +import { usePersonas } from './usePersonas' + +export function useTokenOwner(address: string, tokenId: string, chainId?: ChainId) { + const ERC721Contract = useERC721TokenContract(address, chainId ?? ChainId.Mainnet) + return useAsyncRetry(async () => { + if (!ERC721Contract || !tokenId) return + const nft = await getNFTByOpensea(address, tokenId) + if (nft) return nft + const allSettled = await Promise.allSettled([ + safeNonPayableTransactionCall(ERC721Contract?.methods.ownerOf(tokenId)), + safeNonPayableTransactionCall(ERC721Contract.methods.name()), + safeNonPayableTransactionCall(ERC721Contract.methods.symbol()), + ]) + const result = allSettled.map((x) => (x.status === 'fulfilled' ? x.value : undefined)) + return { owner: result[0], name: result[1], symbol: result[2] } + }, [ERC721Contract, tokenId]) +} + +export function useCheckTokenOwner(userId: string, owner?: string) { + const account = useAccount() + const { value: persona, loading } = usePersonas(userId) + const { value: address, loading: loadingAddress } = useAsyncRetry( + () => PluginNFTAvatarRPC.getAddress(userId, activatedSocialNetworkUI.networkIdentifier), + [userId], + ) + + return { + loading: loading || loadingAddress, + isOwner: Boolean( + (address && owner && isSameAddress(address, owner)) || + persona?.wallets.some((x) => isSameAddress(x.identity, owner)), + ), + } +} diff --git a/packages/mask/src/plugins/Avatar/locales/en-US.json b/packages/mask/src/plugins/Avatar/locales/en-US.json new file mode 100644 index 000000000000..a267c4b7a6bb --- /dev/null +++ b/packages/mask/src/plugins/Avatar/locales/en-US.json @@ -0,0 +1,27 @@ +{ + "application_dialog_title": "NFT PFP", + "application_edit_profile_dialog_title": "Edit Profile", + "persona_set_nft": "Set NFT PFPs", + "persona_verification_failed": "NFT PFP verification failed", + "cancel": "Cancel", + "save": "Save", + "change": "Change", + "connect_your_wallet": "Connect your wallet", + "upload_avatar_failed_message": "Sorry, failed to save NFT Avatar. Please set again.", + "upload_avatar_success_message": "Update NFT Avatar Success!", + "collectible_not_found": "Can' find it.", + "add_collectible": "Add Collectibles", + "set_avatar_title": "Set NFT Avatar", + "set_PFP_title": "Set NFT PFP", + "wallet_settings": "Wallet settings", + "persona_hint": "Customize NFT experience by connecting social accounts. Enjoy Web2 with a whole new Web3 vibe.", + "copy_success_of_wallet_address": "Copy wallet address successfully!", + "copied": "Copied", + "collectible_on_polygon": "Only NFTs on Ethereum are supported to preview. We are currently
working on supporting Polygon NFTs as well.
Please add your collectibles here.", + "collectible_no_eth": "No any collectible is available to preview. Please add your collectible here.", + "no_collectible_found": "No collectible found.", + "retry": "Retry", + "application_hint": "Socialize and show off your NFTs. People can bid,buy, view your valuable NFTs without leaving Twitter.", + "provider_by": "Provided by", + "wallet_non_evm_warning": "The NFT PFP feature currently supports only EVM chains. Support for other
chains will be added in the future." +} diff --git a/packages/mask/src/plugins/Avatar/locales/index.ts b/packages/mask/src/plugins/Avatar/locales/index.ts new file mode 100644 index 000000000000..d6ead60252e4 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/locales/index.ts @@ -0,0 +1,6 @@ +// This file is auto generated. DO NOT EDIT +// Run `npx gulp sync-languages` to regenerate. +// Default fallback language in a family of languages are chosen by the alphabet order +// To overwrite this, please overwrite packages/scripts/src/locale-kit-next/index.ts + +export * from './i18n_generated' diff --git a/packages/mask/src/plugins/Avatar/locales/languages.ts b/packages/mask/src/plugins/Avatar/locales/languages.ts new file mode 100644 index 000000000000..db91c03aa516 --- /dev/null +++ b/packages/mask/src/plugins/Avatar/locales/languages.ts @@ -0,0 +1,19 @@ +// This file is auto generated. DO NOT EDIT +// Run `npx gulp sync-languages` to regenerate. +// Default fallback language in a family of languages are chosen by the alphabet order +// To overwrite this, please overwrite packages/scripts/src/locale-kit-next/index.ts +import en_US from './en-US.json' +export const languages = { + en: en_US, +} +// @ts-ignore +if (import.meta.webpackHot) { + // @ts-ignore + import.meta.webpackHot.accept(['./en-US.json'], () => + globalThis.dispatchEvent?.( + new CustomEvent('MASK_I18N_HMR', { + detail: ['com.maskbook.avatar', { en: en_US }], + }), + ), + ) +} diff --git a/packages/mask/src/plugins/Avatar/types.ts b/packages/mask/src/plugins/Avatar/types.ts index f61a59d91dc9..8fddf38cc416 100644 --- a/packages/mask/src/plugins/Avatar/types.ts +++ b/packages/mask/src/plugins/Avatar/types.ts @@ -1,9 +1,11 @@ +import type { ChainId, ERC721TokenDetailed } from '@masknet/web3-shared-evm' + export interface AvatarMetaDB { userId: string tokenId: string address: string avatarId: string - updateFlag?: boolean + chainId?: ChainId } export interface NFT { @@ -14,3 +16,19 @@ export interface NFT { owner: string slug: string } + +export interface SelectTokenInfo { + account: string + token: ERC721TokenDetailed + image: string +} + +export interface TokenInfo { + address: string + tokenId: string +} + +export interface NextIDAvatarMeta extends AvatarMetaDB { + nickname: string + imageUrl: string +} diff --git a/packages/mask/src/plugins/Avatar/utils/index.ts b/packages/mask/src/plugins/Avatar/utils/index.ts index df7dfe24f71f..3c03939f0808 100644 --- a/packages/mask/src/plugins/Avatar/utils/index.ts +++ b/packages/mask/src/plugins/Avatar/utils/index.ts @@ -1,16 +1,49 @@ -import { isNull } from 'lodash-unified' -import { ChainId, NonFungibleAssetProvider, formatBalance } from '@masknet/web3-shared-evm' +import { first, isNull } from 'lodash-unified' +import { + ChainId, + NonFungibleAssetProvider, + formatBalance, + createContract, + createWeb3, + getERC721ContractDetailed, + getERC721TokenDetailedFromChain, + ERC721TokenDetailed, + getERC721TokenAssetFromChain, +} from '@masknet/web3-shared-evm' import { EVM_RPC } from '../../EVM/messages' import Services from '../../../extension/service' -import { getOrderUnitPrice, NonFungibleTokenAPI } from '@masknet/web3-providers' +import { getOrderUnitPrice, NextIDProof, NextIDStorage, NonFungibleTokenAPI } from '@masknet/web3-providers' import { ZERO } from '@masknet/web3-shared-base' import BigNumber from 'bignumber.js' +import { activatedSocialNetworkUI } from '../../../social-network' +import type { NextIDPersonaBindings, NextIDPlatform } from '@masknet/shared-base' +import type { NextIDAvatarMeta } from '../types' +import { PLUGIN_ID } from '../constants' +import type { ERC721 } from '@masknet/web3-contracts/types/ERC721' +import type { AbiItem } from 'web3-utils' +import ERC721ABI from '@masknet/web3-contracts/abis/ERC721.json' function getLastSalePrice(lastSale?: NonFungibleTokenAPI.AssetEvent | null) { if (!lastSale?.total_price || !lastSale?.payment_token?.decimals) return return formatBalance(lastSale.total_price, lastSale.payment_token.decimals) } +export async function getNFTByOpensea(address: string, tokenId: string) { + const asset = await EVM_RPC.getAsset({ + address, + tokenId, + chainId: ChainId.Mainnet, + provider: NonFungibleAssetProvider.OPENSEA, + }) + + if (!asset) return + return { + name: asset?.name ?? '', + symbol: asset?.desktopOrder?.payment_token_contract?.symbol ?? 'ETH', + owner: asset?.top_ownerships[0].owner.address ?? '', + } +} + export async function getNFT(address: string, tokenId: string) { const asset = await EVM_RPC.getAsset({ address, @@ -31,13 +64,48 @@ export async function getNFT(address: string, tokenId: string) { name: asset?.name ?? '', symbol: asset?.desktopOrder?.payment_token_contract?.symbol ?? 'ETH', image: asset?.image_url ?? '', - owner: asset?.top_ownerships[0].owner.address ?? '', + owner: asset?.top_ownerships?.[0]?.owner.address ?? '', slug: asset?.slug ?? '', } } -export async function createNFT(address: string, tokenId: string) { - return EVM_RPC.getNFT({ address, tokenId, chainId: ChainId.Mainnet, provider: NonFungibleAssetProvider.OPENSEA }) +export async function getNFTByChain( + address: string, + tokenId: string, + chainId: ChainId, +): Promise { + const web3 = createWeb3(Services.Ethereum.request, () => ({ + chainId, + })) + const contract = createContract(web3, address, ERC721ABI as AbiItem[]) + if (!contract) return + + const contractDetailed = await getERC721ContractDetailed(contract, address, chainId) + const tokenDetailedFromChain = await getERC721TokenDetailedFromChain(contractDetailed, contract, tokenId) + if (!tokenDetailedFromChain) return + const info = await getERC721TokenAssetFromChain(tokenDetailedFromChain?.info.tokenURI) + const owner = await contract.methods.ownerOf(tokenId).call() + if (info && tokenDetailedFromChain) { + tokenDetailedFromChain.info = { + ...info, + owner, + ...tokenDetailedFromChain.info, + hasTokenDetailed: true, + name: info?.name ?? tokenDetailedFromChain?.info.name ?? '', + } + } + return tokenDetailedFromChain +} + +export async function createNFT(address: string, tokenId: string, chainId?: ChainId) { + const token = await getNFTByChain(address, tokenId, chainId ?? ChainId.Mainnet) + if (token) return token + return EVM_RPC.getNFT({ + address, + tokenId, + chainId: chainId ?? ChainId.Mainnet, + provider: chainId === ChainId.Matic ? NonFungibleAssetProvider.NFTSCAN : NonFungibleAssetProvider.OPENSEA, + }) } export async function getImage(image: string): Promise { @@ -91,3 +159,39 @@ export function formatText(name: string, tokenId: string) { } return `${_name} #${token}` } + +export function formatTokenId(symbol: string, tokenId: string) { + const name = tokenId + return name.length > 18 ? name.slice(0, 12) + '...' : name +} + +export function formatAddress(address: string, size = 0) { + if (size === 0 || size >= 20) return address + return `${address.slice(0, Math.max(0, 2 + size))}...${address.slice(-size)}` +} + +export const sortPersonaBindings = (a: NextIDPersonaBindings, b: NextIDPersonaBindings, userId: string): number => { + const p_a = first(a.proofs.filter((x) => x.identity === userId.toLowerCase())) + const p_b = first(b.proofs.filter((x) => x.identity === userId.toLowerCase())) + + if (!p_a || !p_b) return 0 + if (p_a.created_at > p_b.created_at) return -1 + return 1 +} + +export async function getNFTAvatarByUserId(userId: string, avatarId: string): Promise { + const platform = activatedSocialNetworkUI.configuration.nextIDConfig?.platform as NextIDPlatform + const bindings = await NextIDProof.queryExistedBindingByPlatform(platform, userId.toLowerCase()) + + for (const binding of bindings.sort((a, b) => sortPersonaBindings(a, b, userId))) { + const response = await NextIDStorage.getByIdentity( + binding.persona, + platform, + userId.toLowerCase(), + PLUGIN_ID, + ) + if (!avatarId && response.ok) return response.val + if (response.ok && response.val.avatarId === avatarId) return response.val + } + return +} diff --git a/packages/mask/src/plugins/FindTruman/SNSAdaptor/index.tsx b/packages/mask/src/plugins/FindTruman/SNSAdaptor/index.tsx index 2d7f34795942..ee079f50d649 100644 --- a/packages/mask/src/plugins/FindTruman/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/FindTruman/SNSAdaptor/index.tsx @@ -92,6 +92,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(251, 49, 32, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent(EntryComponentProps) { @@ -101,6 +102,7 @@ const sns: Plugin.SNSAdaptor.Definition = { setOpen(true))} /> @@ -111,6 +113,7 @@ const sns: Plugin.SNSAdaptor.Definition = { appBoardSortingDefaultPriority: 12, icon, name: , + iconFilterColor, } })(), ], diff --git a/packages/mask/src/plugins/ITO/SNSAdaptor/index.tsx b/packages/mask/src/plugins/ITO/SNSAdaptor/index.tsx index a25c322daaf4..defdb691d080 100644 --- a/packages/mask/src/plugins/ITO/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/ITO/SNSAdaptor/index.tsx @@ -59,6 +59,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(56, 228, 239, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent(EntryComponentProps) { @@ -67,6 +68,7 @@ const sns: Plugin.SNSAdaptor.Definition = { {...EntryComponentProps} title={name} icon={icon} + iconFilterColor={iconFilterColor} onClick={ EntryComponentProps.onClick ?? (() => @@ -84,6 +86,7 @@ const sns: Plugin.SNSAdaptor.Definition = { appBoardSortingDefaultPriority: 3, marketListSortingPriority: 3, icon, + iconFilterColor, description: , category: 'dapp', name, @@ -93,6 +96,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(240, 51, 51, 0.3)' return { ApplicationEntryID: `${base.ID}_claim`, RenderEntryComponent(EntryComponentProps) { @@ -101,6 +105,7 @@ const sns: Plugin.SNSAdaptor.Definition = { <> setOpen(true))} @@ -112,6 +117,7 @@ const sns: Plugin.SNSAdaptor.Definition = { appBoardSortingDefaultPriority: 4, icon, name, + iconFilterColor, } })(), ], diff --git a/packages/mask/src/plugins/MaskBox/SNSAdaptor/index.tsx b/packages/mask/src/plugins/MaskBox/SNSAdaptor/index.tsx index f40ea9b5b7f5..51fec2cdb271 100644 --- a/packages/mask/src/plugins/MaskBox/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/MaskBox/SNSAdaptor/index.tsx @@ -33,6 +33,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(0, 87, 255, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent({ disabled }) { @@ -40,6 +41,7 @@ const sns: Plugin.SNSAdaptor.Definition = { openWindow('https://box.mask.io/#/')} /> @@ -51,6 +53,7 @@ const sns: Plugin.SNSAdaptor.Definition = { tutorialLink: 'https://realmasknetwork.notion.site/d0941687649a4ef7a38d71f23ecbe4da', description: , category: 'dapp', + iconFilterColor, name, } })(), diff --git a/packages/mask/src/plugins/Pets/SNSAdaptor/index.tsx b/packages/mask/src/plugins/Pets/SNSAdaptor/index.tsx index 6ff2c8752464..915e88770a5c 100644 --- a/packages/mask/src/plugins/Pets/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/Pets/SNSAdaptor/index.tsx @@ -22,6 +22,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(226, 0, 233, 0.2)' return { ApplicationEntryID: base.ID, RenderEntryComponent(EntryComponentProps) { @@ -32,6 +33,7 @@ const sns: Plugin.SNSAdaptor.Definition = { {...EntryComponentProps} title={name} icon={icon} + iconFilterColor={iconFilterColor} onClick={EntryComponentProps.onClick ?? openDialog} /> ) @@ -42,6 +44,7 @@ const sns: Plugin.SNSAdaptor.Definition = { description: , name, tutorialLink: 'https://twitter.com/NonFFriend', + iconFilterColor, category: 'dapp', } })(), diff --git a/packages/mask/src/plugins/Savings/SNSAdaptor/index.tsx b/packages/mask/src/plugins/Savings/SNSAdaptor/index.tsx index 2683744563f4..2d3693295dac 100644 --- a/packages/mask/src/plugins/Savings/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/Savings/SNSAdaptor/index.tsx @@ -13,6 +13,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(255, 83, 146, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent(EntryComponentProps) { @@ -22,6 +23,7 @@ const sns: Plugin.SNSAdaptor.Definition = { setOpen(true))} /> @@ -32,6 +34,7 @@ const sns: Plugin.SNSAdaptor.Definition = { appBoardSortingDefaultPriority: 7, icon, name, + iconFilterColor, } })(), ], diff --git a/packages/mask/src/plugins/Tips/SNSAdaptor/index.tsx b/packages/mask/src/plugins/Tips/SNSAdaptor/index.tsx index 8a6f38fa2d51..8800886982fa 100644 --- a/packages/mask/src/plugins/Tips/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/Tips/SNSAdaptor/index.tsx @@ -18,6 +18,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const name = base.name const icon = + const iconFilterColor = 'rgba(247, 147, 30, 0.3)' return { RenderEntryComponent(EntryComponentProps) { const [open, setOpen] = useState(false) @@ -27,6 +28,7 @@ const sns: Plugin.SNSAdaptor.Definition = { } {...EntryComponentProps} + iconFilterColor={iconFilterColor} icon={icon} onClick={EntryComponentProps.onClick ?? (() => setOpen(true))} /> @@ -38,6 +40,7 @@ const sns: Plugin.SNSAdaptor.Definition = { ApplicationEntryID: base.ID, icon, name, + iconFilterColor, appBoardSortingDefaultPriority: 8, nextIdRequired: true, } diff --git a/packages/mask/src/plugins/Trader/SNSAdaptor/index.tsx b/packages/mask/src/plugins/Trader/SNSAdaptor/index.tsx index 2594adcb01a4..a6f9ab174c5c 100644 --- a/packages/mask/src/plugins/Trader/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/Trader/SNSAdaptor/index.tsx @@ -27,6 +27,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(247, 147, 30, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent(EntryComponentProps) { @@ -37,6 +38,7 @@ const sns: Plugin.SNSAdaptor.Definition = { {...EntryComponentProps} title={name} icon={icon} + iconFilterColor={iconFilterColor} onClick={EntryComponentProps.onClick ?? openDialog} /> ) @@ -48,6 +50,7 @@ const sns: Plugin.SNSAdaptor.Definition = { name, tutorialLink: 'https://realmasknetwork.notion.site/f2e7d081ee38487ca1db958393ac1edc', description: , + iconFilterColor, } })(), ], diff --git a/packages/mask/src/plugins/Transak/SNSAdaptor/index.tsx b/packages/mask/src/plugins/Transak/SNSAdaptor/index.tsx index 17c21b451749..e378b0370b1d 100644 --- a/packages/mask/src/plugins/Transak/SNSAdaptor/index.tsx +++ b/packages/mask/src/plugins/Transak/SNSAdaptor/index.tsx @@ -17,6 +17,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = + const iconFilterColor = 'rgba(69, 110, 255, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent(EntryComponentProps) { @@ -26,6 +27,7 @@ const sns: Plugin.SNSAdaptor.Definition = { @@ -38,6 +40,7 @@ const sns: Plugin.SNSAdaptor.Definition = { description: , name, category: 'dapp', + iconFilterColor, } })(), ], diff --git a/packages/mask/src/resources/NFTAvatar.tsx b/packages/mask/src/resources/NFTAvatar.tsx new file mode 100644 index 000000000000..fd2272b36ca7 --- /dev/null +++ b/packages/mask/src/resources/NFTAvatar.tsx @@ -0,0 +1,28 @@ +import { SvgIconProps, SvgIcon } from '@mui/material' + +const svg = ( + + + + + + + + + + +) + +export const NFTAvatarsIcon = (props: SvgIconProps) => {svg} diff --git a/packages/mask/src/social-network-adaptor/facebook.com/utils/selector.ts b/packages/mask/src/social-network-adaptor/facebook.com/utils/selector.ts index a8db839959cf..4cadcc089825 100644 --- a/packages/mask/src/social-network-adaptor/facebook.com/utils/selector.ts +++ b/packages/mask/src/social-network-adaptor/facebook.com/utils/selector.ts @@ -71,7 +71,9 @@ export const searchFacebookProfileSettingButtonSelector = () => querySelector('[role="button"] [role="img"]').closest(10).querySelector('input[type="file"] ~ div').closest(2) export const searchFacebookEditProfileSelector = () => - querySelector('[role="button"] [role="img"]').closest(1).querySelector('i[data-visualcompletion="css-img"]') + querySelector('[role="main"] [role="button"] [role="img"]') + .closest(1) + .querySelector('i[data-visualcompletion="css-img"]') export const searchFacebookSaveAvatarButtonSelector = () => new LiveSelector() diff --git a/packages/mask/src/social-network-adaptor/twitter.com/collecting/identity.ts b/packages/mask/src/social-network-adaptor/twitter.com/collecting/identity.ts index 52eb21793cf8..3eca2d3fb5c0 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/collecting/identity.ts +++ b/packages/mask/src/social-network-adaptor/twitter.com/collecting/identity.ts @@ -8,6 +8,7 @@ import { searchSelfHandleSelector, searchSelfNicknameSelector, searchSelfAvatarSelector, + searchWatcherAvatarSelector, selfInfoSelectors, } from '../utils/selector' import { creator, SocialNetworkUI as Next } from '../../../social-network' @@ -48,9 +49,11 @@ function resolveLastRecognizedIdentityInner( ref: Next.CollectingCapabilities.IdentityResolveProvider['recognized'], cancel: AbortSignal, ) { - const task = isMobileTwitter ? recognizeMobile() : recognizeDesktop() - const assign = () => { - const { handle, nickname, avatar } = task.collect() + const assign = async () => { + await delay(5000) + const avatar = searchSelfAvatarSelector().evaluate()?.getAttribute('src') ?? '' + const handle = searchSelfHandleSelector().evaluate()?.textContent?.trim()?.replace(/^@/, '') + const nickname = searchSelfNicknameSelector().evaluate()?.textContent?.trim() ?? '' if (handle) { ref.value = { @@ -60,14 +63,29 @@ function resolveLastRecognizedIdentityInner( } } } - const watcher = task.watcher - .addListener('onAdd', () => assign()) - .addListener('onChange', () => assign()) - .startWatch({ - childList: true, - subtree: true, + + const createWatcher = (selector: LiveSelector) => { + const watcher = new MutationObserverWatcher(selector) + .addListener('onAdd', () => assign()) + .addListener('onChange', () => assign()) + .startWatch({ + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['src'], + }) + + window.addEventListener('locationchange', assign) + cancel.addEventListener('abort', () => { + window.removeEventListener('locationchange', assign) + watcher.stopWatch() }) - cancel.addEventListener('abort', () => watcher.stopWatch()) + } + + assign() + + createWatcher(searchSelfHandleSelector()) + createWatcher(searchWatcherAvatarSelector()) } function resolveLastRecognizedIdentityMobileInner( @@ -99,7 +117,7 @@ function resolveCurrentVisitingIdentityInner( const avatarSelector = searchAvatarSelector() const avatarMetaSelector = searchAvatarMetaSelector() const assign = async () => { - await delay(500) + await delay(5000) const bio = getBio() const homepage = getPersonalHomepage() const nickname = getNickname() diff --git a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarEditProfile.tsx b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarEditProfile.tsx index a13a3ebff6f7..b74ec72baf40 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarEditProfile.tsx +++ b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarEditProfile.tsx @@ -1,6 +1,9 @@ import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' +import { CrossIsolationMessages } from '@masknet/shared-base' import { makeStyles } from '@masknet/theme' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' +import { usePersonaConnectStatus } from '../../../../components/DataSource/usePersonaConnectStatus' +import { NFTAvatarDialog } from '../../../../plugins/Avatar/Application/NFTAvatarsDialog' import { NFTAvatarButton } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatarButton' import { startWatch, createReactRootShadowed, useLocationChange } from '../../../../utils' import { searchEditProfileSelector } from '../../utils/selector' @@ -17,6 +20,8 @@ interface StyleProps { minHeight: number fontSize: number marginBottom: number + border: string + color: string } const useStyles = makeStyles()((theme, props) => ({ root: { @@ -24,7 +29,11 @@ const useStyles = makeStyles()((theme, props) => ({ fontSize: props.fontSize, marginBottom: props.marginBottom, marginTop: 1, - marginRight: theme.spacing(0.5), + marginRight: theme.spacing(2), + border: props.border, + }, + text: { + color: props.color, }, })) @@ -34,16 +43,43 @@ export function openNFTAvatarSettingDialog() { } function OpenNFTAvatarEditProfileButtonInTwitter() { - const [style, setStyle] = useState({ minHeight: 32, fontSize: 14, marginBottom: 11 }) + const [style, setStyle] = useState({ + minHeight: 32, + fontSize: 14, + marginBottom: 11, + border: 'none', + color: '', + }) + const [open, setOpen] = useState(false) + + const personaConnectStatus = usePersonaConnectStatus() + + const createOrConnectPersona = useCallback(() => { + personaConnectStatus.action?.() + }, [personaConnectStatus]) + + const verifyPersona = useCallback(() => { + CrossIsolationMessages.events.verifyNextID.sendToAll(undefined) + }, []) + + const clickHandler = (() => { + if (personaConnectStatus.hasPersona === false) return createOrConnectPersona + if (personaConnectStatus.connected === false) return verifyPersona + return + })() const setStyleFromEditProfileSelector = () => { const editDom = searchEditProfileSelector().evaluate() if (!editDom) return + const css = window.getComputedStyle(editDom) + const spanCss = window.getComputedStyle(editDom.querySelector('span')!) setStyle({ minHeight: Number(css.minHeight.replace('px', '')), fontSize: Number(css.fontSize.replace('px', '')), marginBottom: Number(css.marginBottom.replace('px', '')), + border: css.border, + color: spanCss.color, }) } @@ -52,5 +88,13 @@ function OpenNFTAvatarEditProfileButtonInTwitter() { useLocationChange(() => setStyleFromEditProfileSelector()) const { classes } = useStyles(style) - return + return ( + <> + setOpen(true))} + /> + setOpen(false)} /> + + ) } diff --git a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarInTwitter.tsx b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarInTwitter.tsx index cefd28736ec5..1fb279a56651 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarInTwitter.tsx +++ b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/NFTAvatarInTwitter.tsx @@ -6,7 +6,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useCurrentVisitingIdentity } from '../../../../components/DataSource/useActivatedUI' import { resolveOpenSeaLink, useWallet } from '@masknet/web3-shared-evm' import type { AvatarMetaDB } from '../../../../plugins/Avatar/types' -import { useNFTAvatar } from '../../../../plugins/Avatar/hooks' import { getAvatarId } from '../../utils/user' import { PluginNFTAvatarRPC } from '../../../../plugins/Avatar/messages' import { NFTBadge } from '../../../../plugins/Avatar/SNSAdaptor/NFTBadge' @@ -16,6 +15,7 @@ import { rainbowBorderKeyFrames } from '../../../../plugins/Avatar/SNSAdaptor/Ra import { trim } from 'lodash-unified' import { RSS3_KEY_SNS } from '../../../../plugins/Avatar/constants' import { openWindow } from '@masknet/shared-base-ui' +import { usePersonaNFTAvatar } from '../../../../plugins/Avatar/hooks/usePersonaNFTAvatar' export function injectNFTAvatarInTwitter(signal: AbortSignal) { const watcher = new MutationObserverWatcher(searchTwitterAvatarSelector()) @@ -52,7 +52,11 @@ function NFTAvatarInTwitter() { const borderElement = useRef() const identity = useCurrentVisitingIdentity() const wallet = useWallet() - const { value: _avatar } = useNFTAvatar(identity.identifier?.userId, RSS3_KEY_SNS.TWITTER) + const { value: _avatar } = usePersonaNFTAvatar( + identity.identifier?.userId ?? '', + getAvatarId(identity.avatar ?? ''), + RSS3_KEY_SNS.TWITTER, + ) const [avatar, setAvatar] = useState() const windowSize = useWindowSize() const location = useLocation() @@ -196,7 +200,7 @@ function NFTAvatarInTwitter() { if (!avatar || !linkParentDom || !showAvatar) return const handler = () => { - openWindow(resolveOpenSeaLink(avatar.address, avatar.tokenId)) + openWindow(resolveOpenSeaLink(avatar.address, avatar.tokenId, avatar.chainId)) } linkParentDom.addEventListener('click', handler) diff --git a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/ProfileNFTAvatar.tsx b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/ProfileNFTAvatar.tsx deleted file mode 100644 index d5f59f5f0b5c..000000000000 --- a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/ProfileNFTAvatar.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' -import { makeStyles } from '@masknet/theme' -import { useEffect, useState } from 'react' -import { createReactRootShadowed, MaskMessages, NFTAvatarEvent, startWatch } from '../../../../utils' -import { - searchTwitterAvatarOpenFilesSelector, - searchProfessionalButtonSelector, - searchProfileSaveSelector, - searchProfileSetAvatarSelector, -} from '../../utils/selector' -import { useCurrentVisitingIdentity } from '../../../../components/DataSource/useActivatedUI' -import { getAvatarId } from '../../utils/user' -import { toPNG } from '../../../../plugins/Avatar/utils' -import type { ERC721TokenDetailed } from '@masknet/web3-shared-evm' -import { NFTAvatar } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatar' -import { hookInputUploadOnce } from '@masknet/injected-script' - -export async function injectProfileNFTAvatarInTwitter(signal: AbortSignal) { - const watcher = new MutationObserverWatcher(searchProfileSetAvatarSelector()) - startWatch(watcher, signal) - createReactRootShadowed(watcher.firstDOMProxy.afterShadow, { signal }).render() -} - -const useStyles = makeStyles<{ - paddingTop: string - paddingLeft: string - paddingRight: string - paddingBottom: string -}>()((theme, props) => ({ - root: { - paddingLeft: props.paddingLeft, - paddingTop: props.paddingTop, - paddingRight: props.paddingRight, - paddingBottom: props.paddingBottom, - }, -})) - -function getStyles() { - const ele = searchProfessionalButtonSelector().evaluate() - - if (!ele) - return { - paddingTop: '11px', - paddingLeft: '14px', - paddingRight: '14px', - paddingBottom: '14px', - } - const style = window.getComputedStyle(ele) - return { - paddingTop: style.paddingTop, - paddingLeft: style.paddingLeft, - paddingRight: style.paddingRight, - paddingBottom: style.paddingBottom, - } -} - -async function changeImageToActiveElements(image: File | Blob): Promise { - const imageBuffer = await image.arrayBuffer() - hookInputUploadOnce('image/png', 'avatar.png', new Uint8Array(imageBuffer)) - ;(searchTwitterAvatarOpenFilesSelector().evaluate()[0]?.parentElement?.children[0] as HTMLElement)?.click() -} - -function NFTAvatarInTwitter() { - const { classes } = useStyles(getStyles()) - const identity = useCurrentVisitingIdentity() - - const [avatarEvent, setAvatarEvent] = useState() - - const onChange = async (token: ERC721TokenDetailed) => { - if (!token.info.imageURL) return - const image = await toPNG(token.info.imageURL) - if (!image) return - if (!identity.identifier) return - changeImageToActiveElements(image) - - setAvatarEvent({ - userId: identity.identifier.userId, - avatarId: getAvatarId(identity.avatar ?? ''), - address: token.contractDetailed.address, - tokenId: token.tokenId, - }) - } - - const handler = () => { - if (!identity.identifier) return - MaskMessages.events.NFTAvatarUpdated.sendToLocal( - avatarEvent ?? { - userId: identity.identifier.userId, - avatarId: getAvatarId(identity.avatar ?? ''), - address: '', - tokenId: '', - }, - ) - setAvatarEvent(undefined) - } - - useEffect(() => { - const profileSave = searchProfileSaveSelector().evaluate() - if (!profileSave) return - profileSave.addEventListener('click', handler) - return () => profileSave.removeEventListener('click', handler) - }, [handler]) - - return -} diff --git a/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts b/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts index f7cd82bef7eb..a51c4e3bcb8a 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts +++ b/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts @@ -29,7 +29,6 @@ import { EnhanceableSite, NextIDPlatform, ProfileIdentifier } from '@masknet/sha import { makeStyles } from '@masknet/theme' import { injectNFTAvatarInTwitter } from './injection/NFT/NFTAvatarInTwitter' import { injectOpenTipButtonOnProfile } from './injection/Tip/index' -import { injectProfileNFTAvatarInTwitter } from './injection/NFT/ProfileNFTAvatar' import { injectUserNFTAvatarAtTwitter } from './injection/NFT/Avatar' import { injectOpenNFTAvatarEditProfileButton, openNFTAvatarSettingDialog } from './injection/NFT/NFTAvatarEditProfile' import { injectUserNFTAvatarAtTweet } from './injection/NFT/TweetNFTAvatar' @@ -51,7 +50,7 @@ const useInjectedDialogClassesOverwriteTwitter = makeStyles()((theme) => { alignItems: 'center', }, paper: { - width: '600px !important', + width: '612px !important', maxWidth: 'none', boxShadow: 'none', backgroundImage: 'none', @@ -179,7 +178,6 @@ const twitterUI: SocialNetworkUI.Definition = { userBadge: injectMaskUserBadgeAtTwitter, commentComposition: undefined, userAvatar: injectUserNFTAvatarAtTwitter, - enhancedProfileNFTAvatar: injectProfileNFTAvatarInTwitter, profileAvatar: injectNFTAvatarInTwitter, profileTip: injectOpenTipButtonOnProfile, openNFTAvatar: injectOpenNFTAvatarEditProfileButton, diff --git a/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts b/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts index 9ef0a8486c87..197b49efeb03 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts +++ b/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts @@ -255,6 +255,9 @@ export const searchSelfNicknameSelector = () => { ) } +export const searchWatcherAvatarSelector = () => + querySelector('[data-testid="SideNav_AccountSwitcher_Button"] img') + export const searchSelfAvatarSelector = () => { return querySelector( [ diff --git a/packages/plugin-infra/src/types.ts b/packages/plugin-infra/src/types.ts index 6c2f87585320..15aa383c7e6b 100644 --- a/packages/plugin-infra/src/types.ts +++ b/packages/plugin-infra/src/types.ts @@ -335,6 +335,8 @@ export namespace Plugin.SNSAdaptor { description?: I18NFieldOrReactNode + iconFilterColor?: string + tutorialLink?: string /** * Does the application listed in the DAPP list diff --git a/packages/plugins/CrossChainBridge/src/SNSAdaptor/index.tsx b/packages/plugins/CrossChainBridge/src/SNSAdaptor/index.tsx index 09511e7f3052..d0fb13072772 100644 --- a/packages/plugins/CrossChainBridge/src/SNSAdaptor/index.tsx +++ b/packages/plugins/CrossChainBridge/src/SNSAdaptor/index.tsx @@ -13,6 +13,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = { i18nKey: '__plugin_name', fallback: 'Cross-chain' } + const iconFilterColor = 'rgba(183, 212, 255, 0.3)' return { ApplicationEntryID: base.ID, RenderEntryComponent({ disabled }) { @@ -22,6 +23,7 @@ const sns: Plugin.SNSAdaptor.Definition = { } disabled={disabled} + iconFilterColor={iconFilterColor} icon={icon} onClick={() => setOpen(true)} /> @@ -32,6 +34,7 @@ const sns: Plugin.SNSAdaptor.Definition = { appBoardSortingDefaultPriority: 5, name, icon, + iconFilterColor, } })(), ], diff --git a/packages/plugins/FileService/src/SNSAdaptor/index.tsx b/packages/plugins/FileService/src/SNSAdaptor/index.tsx index 94d5a656f151..5a6fd71d8c6f 100644 --- a/packages/plugins/FileService/src/SNSAdaptor/index.tsx +++ b/packages/plugins/FileService/src/SNSAdaptor/index.tsx @@ -37,22 +37,26 @@ const definition: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = { i18nKey: '__plugin_name', fallback: 'File Service' } + const iconFilterColor = 'rgba(247, 147, 30, 0.3)' return { ApplicationEntryID: base.ID, - RenderEntryComponent({ disabled }) { + RenderEntryComponent(EntryComponentProps) { return ( } - disabled={disabled} + {...EntryComponentProps} icon={icon} - onClick={() => - CrossIsolationMessages.events.requestComposition.sendToLocal({ - reason: 'timeline', - open: true, - options: { - startupPlugin: base.ID, - }, - }) + iconFilterColor={iconFilterColor} + onClick={ + EntryComponentProps.onClick ?? + (() => + CrossIsolationMessages.events.requestComposition.sendToLocal({ + reason: 'timeline', + open: true, + options: { + startupPlugin: base.ID, + }, + })) } /> ) @@ -67,6 +71,7 @@ const definition: Plugin.SNSAdaptor.Definition = { 'Decentralized file storage, permanently. Upload and share files to your Mask friends on top of Arweave Network.', }, name, + iconFilterColor, tutorialLink: 'https://realmasknetwork.notion.site/8c8fe1efce5a48b49739a38f4ea8c60f', } })(), diff --git a/packages/plugins/GoPlusSecurity/src/SNSAdaptor/index.tsx b/packages/plugins/GoPlusSecurity/src/SNSAdaptor/index.tsx index 1a7a963e487b..f4ce3d0d3d84 100644 --- a/packages/plugins/GoPlusSecurity/src/SNSAdaptor/index.tsx +++ b/packages/plugins/GoPlusSecurity/src/SNSAdaptor/index.tsx @@ -13,6 +13,7 @@ const sns: Plugin.SNSAdaptor.Definition = { (() => { const icon = const name = { i18nKey: '__plugin_name', fallback: 'Check Security' } + const iconFilterColor = 'rgba(69, 110, 255, 0.3)' return { ApplicationEntryID: base.ID, @@ -23,6 +24,7 @@ const sns: Plugin.SNSAdaptor.Definition = { } disabled={disabled} + iconFilterColor={iconFilterColor} icon={icon} onClick={() => setOpen(true)} /> @@ -32,6 +34,7 @@ const sns: Plugin.SNSAdaptor.Definition = { }, name, icon, + iconFilterColor, appBoardSortingDefaultPriority: 13, marketListSortingPriority: 16, } diff --git a/packages/shared/src/UI/components/ApplicationEntry/index.tsx b/packages/shared/src/UI/components/ApplicationEntry/index.tsx index 56a241599d1b..96192f8a466d 100644 --- a/packages/shared/src/UI/components/ApplicationEntry/index.tsx +++ b/packages/shared/src/UI/components/ApplicationEntry/index.tsx @@ -3,89 +3,105 @@ import type { Plugin } from '@masknet/plugin-infra' import { makeStyles, ShadowRootTooltip } from '@masknet/theme' import { Typography } from '@mui/material' -const useStyles = makeStyles<{ disabled: boolean }>()((theme, { disabled }) => ({ - applicationBox: { - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - backgroundColor: theme.palette.background.default, - borderRadius: '8px', - height: 100, - }, - applicationBoxHover: { - cursor: 'pointer', - '&:hover': { - transform: 'scale(1.02) translateY(-2px)', - background: theme.palette.mode === 'light' ? theme.palette.common.white : theme.palette.common.black, - boxShadow: theme.palette.mode === 'light' ? '0 5px 8px rgba(0, 0, 0, 0.05)' : 'none', +const useStyles = makeStyles<{ disabled: boolean; iconFilterColor?: string }>()( + (theme, { disabled, iconFilterColor }) => ({ + applicationBox: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.palette.background.default, + borderRadius: '8px', + height: 100, }, - }, - applicationImg: { - width: 36, - height: 36, - marginBottom: 10, - }, - title: { - fontSize: 15, - }, - disabled: { - opacity: 0.4, - cursor: 'default !important', - pointerEvent: 'none', - }, - iconWrapper: { - '> *': { + applicationBoxHover: { + cursor: 'pointer', + '&:hover': { + transform: 'scale(1.02) translateY(-2px)', + background: theme.palette.mode === 'light' ? theme.palette.common.white : theme.palette.common.black, + boxShadow: theme.palette.mode === 'light' ? '0px 5px 8px rgba(0, 0, 0, 0.05)' : 'none', + }, + }, + applicationImg: { width: 36, height: 36, + marginBottom: 10, + }, + title: { + fontSize: 15, + }, + disabled: { + opacity: 0.4, + cursor: 'default !important', + pointerEvent: 'none', + }, + iconWrapper: { + '> *': { + width: 36, + height: 36, + }, + ...(iconFilterColor + ? { filter: `drop-shadow(0px 6px 12px ${iconFilterColor})`, backdropFilter: 'blur(16px)' } + : {}), + }, + tooltip: { + backgroundColor: theme.palette.mode === 'light' ? theme.palette.common.black : theme.palette.common.white, + }, + tooltipHint: { + fontSize: 14, + color: theme.palette.mode === 'light' ? theme.palette.common.white : theme.palette.common.black, + }, + arrow: { + color: theme.palette.mode === 'light' ? theme.palette.common.black : theme.palette.common.white, + }, + recommendFeatureApplicationBox: { + width: 260, + minWidth: 260, + height: 97, + marginRight: 12, + cursor: 'pointer', + display: 'inline-flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 10, + borderRadius: 8, + }, + recommendFeatureAppIconWrapper: { + marginRight: 12, + '> *': { + width: 48, + height: 48, + }, + }, + recommendFeatureAppListItemName: { + fontSize: 14, + fontWeight: 500, + cursor: disabled ? 'default' : 'pointer', + color: theme.palette.common.white, }, - }, - recommendFeatureApplicationBox: { - width: 225, - minWidth: 225, - height: 97, - marginRight: 12, - cursor: 'pointer', - display: 'inline-flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 10, - borderRadius: 8, - }, - recommendFeatureAppIconWrapper: { - marginRight: 12, - '> *': { - width: 48, - height: 48, + recommendFeatureAppListItemDescription: { + fontSize: 12, + fontWeight: 500, + cursor: disabled ? 'default' : 'pointer', + color: theme.palette.common.white, }, - }, - recommendFeatureAppListItemName: { - fontSize: 14, - fontWeight: 500, - cursor: disabled ? 'default' : 'pointer', - color: theme.palette.common.white, - }, - recommendFeatureAppListItemDescription: { - fontSize: 12, - fontWeight: 500, - cursor: disabled ? 'default' : 'pointer', - color: theme.palette.common.white, - }, -})) + }), +) interface ApplicationEntryProps { icon: React.ReactNode title: React.ReactNode disabled?: boolean recommendFeature?: Plugin.SNSAdaptor.ApplicationEntry['recommendFeature'] - tooltipHint?: string + iconFilterColor?: string + tooltipHint?: string | React.ReactElement onClick: () => void } export function ApplicationEntry(props: ApplicationEntryProps) { - const { title, onClick, disabled = false, icon, tooltipHint, recommendFeature } = props - const { classes } = useStyles({ disabled }) + const { title, onClick, disabled = false, icon, tooltipHint, recommendFeature, iconFilterColor } = props + const { classes } = useStyles({ disabled, iconFilterColor }) const jsx = recommendFeature ? (
{tooltipHint}}> + title={{tooltipHint}}> {jsx} ) : ( diff --git a/packages/shared/src/contexts/components/InjectedDialog.tsx b/packages/shared/src/contexts/components/InjectedDialog.tsx index f62c51d8da29..a81a51cf4ea5 100644 --- a/packages/shared/src/contexts/components/InjectedDialog.tsx +++ b/packages/shared/src/contexts/components/InjectedDialog.tsx @@ -152,7 +152,11 @@ export function InjectedDialog(props: InjectedDialogProps) { aria-label={t.dialog_dismiss()} onClick={onClose}> diff --git a/packages/web3-providers/src/NextID/kv.ts b/packages/web3-providers/src/NextID/kv.ts index d9dcaefcaf4f..08e323a8f23f 100644 --- a/packages/web3-providers/src/NextID/kv.ts +++ b/packages/web3-providers/src/NextID/kv.ts @@ -4,7 +4,7 @@ import urlcat from 'urlcat' import type { NextIDStoragePayload, NextIDPlatform } from '@masknet/shared-base' import { fetchJSON } from './helper' -import type { Result } from 'ts-results' +import { Err, Ok, Result } from 'ts-results' import type { NextIDBaseAPI } from '../types' import { KV_BASE_URL_DEV, KV_BASE_URL_PROD } from './constants' @@ -29,10 +29,30 @@ export class NextIDStorageAPI implements NextIDBaseAPI.Storage { * @param personaPublicKey * */ + async getByIdentity( + personaPublicKey: string, + platform: NextIDPlatform, + identity: string, + pluginId: string, + ): Promise> { + const response = await fetchJSON<{ + persona: string + proofs: { + platform: NextIDPlatform + identity: string + content: Record + }[] + }>(urlcat(BASE_URL, '/v1/kv', { persona: personaPublicKey })) + if (!response.ok) return Err('User not found') + + const data = + response.val.proofs?.filter((x) => x.identity === identity.toLowerCase() && x.platform === platform) ?? [] + if (!data.length) return Err('Not found') + return Ok(data[0].content[pluginId]) + } async get(personaPublicKey: string): Promise> { return fetchJSON(urlcat(BASE_URL, '/v1/kv', { persona: personaPublicKey })) } - /** * Get signature payload for updating * @param personaPublicKey diff --git a/packages/web3-providers/src/twitter/index.ts b/packages/web3-providers/src/twitter/index.ts index 8b38595f9116..83533ae075ea 100644 --- a/packages/web3-providers/src/twitter/index.ts +++ b/packages/web3-providers/src/twitter/index.ts @@ -128,7 +128,7 @@ export class TwitterAPI implements TwitterBaseAPI.Provider { } } - async uploadUserAvatar(image: File | Blob) { + async uploadUserAvatar(screenName: string, image: File | Blob): Promise { // INIT const initURL = `${UPLOAD_AVATAR_URL}?command=INIT&total_bytes=${image.size}&media_type=${encodeURIComponent( image.type, @@ -151,21 +151,43 @@ export class TwitterAPI implements TwitterBaseAPI.Provider { // FINALIZE const finalizeURL = `${UPLOAD_AVATAR_URL}?command=FINALIZE&media_id=${mediaId}` - const data = await request<{ - media_id: number - media_id_string: string - size: number - image: { - image_type: string - w: number - h: number - } - }>(finalizeURL, { + return request(finalizeURL, { method: 'POST', credentials: 'include', }) + } + async updateProfileImage(screenName: string, media_id_str: string): Promise { + const { bearerToken, queryToken, csrfToken } = await getTokens() + const headers = { + authorization: `Bearer ${bearerToken}`, + 'x-csrf-token': csrfToken, + 'content-type': 'application/json', + 'x-twitter-auth-type': 'OAuth2Session', + 'x-twitter-active-user': 'yes', + referer: `https://twitter.com/${screenName}`, + } + const updateProfileImageURL = 'https://twitter.com/i/api/1.1/account/update_profile_image.json' + if (!bearerToken || !queryToken || !csrfToken) return + const response = await fetch( + urlcat(updateProfileImageURL, { + media_id: media_id_str, + skip_status: 1, + return_user: true, + }), + { + method: 'POST', + credentials: 'include', + headers, + }, + ) - return data + const updateInfo = await response.json() + return { + imageUrl: updateInfo.profile_image_url_https, + mediaId: updateInfo.id_str, + nickname: updateInfo.name, + userId: updateInfo.screen_name, + } } } diff --git a/packages/web3-providers/src/types.ts b/packages/web3-providers/src/types.ts index 124a9110374d..e33ba8eed336 100644 --- a/packages/web3-providers/src/types.ts +++ b/packages/web3-providers/src/types.ts @@ -9,13 +9,7 @@ import type { } from '@masknet/web3-shared-evm' import type { CurrencyType } from '@masknet/plugin-infra/web3' import type { Result } from 'ts-results' -import type { - NextIDAction, - NextIDStoragePayload, - NextIDPayload, - NextIDPlatform, - NextIDStorageInfo, -} from '@masknet/shared-base' +import type { NextIDAction, NextIDStoragePayload, NextIDPayload, NextIDPlatform } from '@masknet/shared-base' export namespace ExplorerAPI { export type Transaction = Web3Transaction & { @@ -328,7 +322,13 @@ export namespace NextIDBaseAPI { patchData: unknown, pluginId: string, ): Promise> - get(key: string): Promise> + getByIdentity( + key: string, + platform: NextIDPlatform, + identity: string, + pluginId: string, + ): Promise> + get(key: string): Promise> getPayload( personaPublicKey: string, platform: NextIDPlatform, @@ -470,11 +470,28 @@ export namespace TwitterBaseAPI { }[] } } + export interface AvatarInfo { + nickname: string + userId: string + imageUrl: string + mediaId: string + } export interface Settings { screen_name: string } + export interface TwitterResult { + media_id: number + media_id_string: string + size: number + image: { + image_type: string + w: number + h: number + } + } + export interface Provider { getSettings: () => Promise getUserNftContainer: (screenName: string) => Promise< @@ -485,6 +502,8 @@ export namespace TwitterBaseAPI { } | undefined > + uploadUserAvatar: (screenName: string, image: Blob | File) => Promise + updateProfileImage: (screenName: string, media_id_str: string) => Promise } } diff --git a/packages/web3-shared/evm/contracts/useERC721TokenContract.ts b/packages/web3-shared/evm/contracts/useERC721TokenContract.ts index 49e4e3d2c548..75a1c81d892d 100644 --- a/packages/web3-shared/evm/contracts/useERC721TokenContract.ts +++ b/packages/web3-shared/evm/contracts/useERC721TokenContract.ts @@ -2,7 +2,8 @@ import type { AbiItem } from 'web3-utils' import ERC721ABI from '@masknet/web3-contracts/abis/ERC721.json' import type { ERC721 } from '@masknet/web3-contracts/types/ERC721' import { useContract } from '../hooks/useContract' +import type { ChainId } from '../types' -export function useERC721TokenContract(address?: string) { - return useContract(address, ERC721ABI as AbiItem[]) +export function useERC721TokenContract(address?: string, chainId?: ChainId) { + return useContract(address, ERC721ABI as AbiItem[], chainId) } diff --git a/packages/web3-shared/evm/hooks/useCollectibles.ts b/packages/web3-shared/evm/hooks/useCollectibles.ts index 57f342812b1b..89e9f992cb60 100644 --- a/packages/web3-shared/evm/hooks/useCollectibles.ts +++ b/packages/web3-shared/evm/hooks/useCollectibles.ts @@ -1,6 +1,6 @@ -import type { ChainId, ERC721TokenCollectionInfo, ERC721TokenDetailed } from '../types' +import { ChainId, ERC721TokenCollectionInfo, ERC721TokenDetailed } from '../types' import { useWeb3Context } from '../context' -import { uniqWith } from 'lodash-unified' +import { noop, uniqWith } from 'lodash-unified' import { isSameAddress } from '../utils' import { useSocket } from './useSocket' import { useMemo } from 'react' @@ -54,9 +54,9 @@ export function useCollectibles(address: string, chainId: ChainId | null, depend (a, b) => isSameAddress(a.contractDetailed.address, b.contractDetailed.address) && a.tokenId === b.tokenId, ) return { - data: all as ERC721TokenDetailed[], + data: chainId === ChainId.Mainnet ? (all as ERC721TokenDetailed[]) : [], state, - error, - retry, + error: chainId === ChainId.Mainnet ? error : undefined, + retry: chainId === ChainId.Mainnet ? retry : noop, } } diff --git a/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts b/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts index 6dd2eb3d033f..cc9397ca4313 100644 --- a/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts +++ b/packages/web3-shared/evm/hooks/useERC721TokenDetailed.ts @@ -117,6 +117,7 @@ export async function getERC721TokenAssetFromChain(tokenURI?: string): Promise=6.12'} dependencies: '@0xproject/typescript-typings': 0.3.2 @@ -1519,7 +1523,7 @@ packages: dev: false /@0xproject/types/0.7.0: - resolution: {integrity: sha1-+tE5Je6SrU7hmAZopcsr7U3Kq48=} + resolution: {integrity: sha512-LlTnq7raPnCioCokNy5CLDxZJeg3KtcHT4PBJD6BDiXYtNJxBWvL7/jr6JiwdjReNg7ihGi1265VKfFX7qukRw==} engines: {node: '>=6.12'} dependencies: '@types/node': 17.0.31 @@ -1527,7 +1531,7 @@ packages: dev: false /@0xproject/typescript-typings/0.3.2: - resolution: {integrity: sha1-q8NgtEGv2pkxAEUMqDad+r91k2s=} + resolution: {integrity: sha512-sHwGTxerREyQhyXFHhZRHvy7PlbBUjsoVbxhf5f+9JasC+IhQ2Nv4uJFjOo16uUPPp7l+lsRUvK/ZwrdGRbFBA==} engines: {node: '>=6.12'} dependencies: '@0xproject/types': 0.7.0 @@ -1761,9 +1765,24 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/helper-explode-assignable-expression': 7.16.7 - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 dev: true + /@babel/helper-compilation-targets/7.16.7: + resolution: {integrity: sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/compat-data': 7.17.0 + '@babel/helper-validator-option': 7.16.7 + browserslist: 4.20.3 + semver: 6.3.0 + dev: false + /@babel/helper-compilation-targets/7.16.7_@babel+core@7.17.10: resolution: {integrity: sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==} engines: {node: '>=6.9.0'} @@ -1796,6 +1815,22 @@ packages: semver: 6.3.0 dev: true + /@babel/helper-compilation-targets/7.17.10_@babel+core@7.17.9: + resolution: {integrity: sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/compat-data': 7.17.10 + '@babel/core': 7.17.9 + '@babel/helper-validator-option': 7.16.7 + browserslist: 4.20.3 + semver: 6.3.0 + dev: true + /@babel/helper-compilation-targets/7.17.7_@babel+core@7.17.10: resolution: {integrity: sha512-UFzlz2jjd8kroj0hmCFV5zr+tQPi1dpC2cRsDV/3IEW8bJfCPrPpmcSN6ZS8RqIq4LXcmpipCQFPddyFA5Yc7w==} engines: {node: '>=6.9.0'} @@ -1907,10 +1942,10 @@ packages: optional: true dependencies: '@babel/core': 7.17.10 - '@babel/helper-compilation-targets': 7.17.7_@babel+core@7.17.10 + '@babel/helper-compilation-targets': 7.17.10_@babel+core@7.17.10 '@babel/helper-module-imports': 7.16.7 '@babel/helper-plugin-utils': 7.16.7 - '@babel/traverse': 7.17.9 + '@babel/traverse': 7.17.10 debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.0 @@ -1928,10 +1963,10 @@ packages: optional: true dependencies: '@babel/core': 7.17.9 - '@babel/helper-compilation-targets': 7.17.7_@babel+core@7.17.9 + '@babel/helper-compilation-targets': 7.17.10_@babel+core@7.17.9 '@babel/helper-module-imports': 7.16.7 '@babel/helper-plugin-utils': 7.16.7 - '@babel/traverse': 7.17.9 + '@babel/traverse': 7.17.10 debug: 4.3.4 lodash.debounce: 4.0.8 resolve: 1.22.0 @@ -1940,6 +1975,26 @@ packages: - supports-color dev: true + /@babel/helper-define-polyfill-provider/0.2.4: + resolution: {integrity: sha512-OrpPZ97s+aPi6h2n1OXzdhVis1SGSsMU2aMHgLcOKfsp4/v1NWpx3CWT3lBj5eeBq9cDkPkh+YCfdF7O12uNDQ==} + peerDependencies: + '@babel/core': ^7.4.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/helper-compilation-targets': 7.16.7 + '@babel/helper-module-imports': 7.16.0 + '@babel/helper-plugin-utils': 7.16.5 + '@babel/traverse': 7.16.10 + debug: 4.3.3 + lodash.debounce: 4.0.8 + resolve: 1.22.0 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: false + /@babel/helper-define-polyfill-provider/0.2.4_@babel+core@7.17.10: resolution: {integrity: sha512-OrpPZ97s+aPi6h2n1OXzdhVis1SGSsMU2aMHgLcOKfsp4/v1NWpx3CWT3lBj5eeBq9cDkPkh+YCfdF7O12uNDQ==} peerDependencies: @@ -2013,7 +2068,7 @@ packages: resolution: {integrity: sha512-KyUenhWMC8VrxzkGP0Jizjo4/Zx+1nNZhgocs+gLzyZyB8SHidhoq9KK/8Ato4anhwsivfkBLftky7gvzbZMtQ==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 dev: true /@babel/helper-function-name/7.16.7: @@ -2037,7 +2092,7 @@ packages: resolution: {integrity: sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.16.8 + '@babel/types': 7.17.10 dev: false /@babel/helper-hoist-variables/7.16.7: @@ -2064,7 +2119,7 @@ packages: resolution: {integrity: sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 /@babel/helper-module-transforms/7.17.7: resolution: {integrity: sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==} @@ -2108,7 +2163,7 @@ packages: dependencies: '@babel/helper-annotate-as-pure': 7.16.7 '@babel/helper-wrap-function': 7.16.8 - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 transitivePeerDependencies: - supports-color dev: true @@ -2130,7 +2185,7 @@ packages: resolution: {integrity: sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 dev: true /@babel/helper-skip-transparent-expression-wrappers/7.16.0: @@ -2165,8 +2220,8 @@ packages: dependencies: '@babel/helper-function-name': 7.17.9 '@babel/template': 7.16.7 - '@babel/traverse': 7.17.9 - '@babel/types': 7.17.0 + '@babel/traverse': 7.17.10 + '@babel/types': 7.17.10 transitivePeerDependencies: - supports-color dev: true @@ -2203,6 +2258,8 @@ packages: resolution: {integrity: sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A==} engines: {node: '>=6.0.0'} hasBin: true + dependencies: + '@babel/types': 7.17.10 dev: false /@babel/parser/7.17.10: @@ -4203,6 +4260,8 @@ packages: dependencies: '@babel/helper-annotate-as-pure': 7.16.7 '@babel/helper-plugin-utils': 7.16.7 + '@babel/plugin-syntax-jsx': 7.16.7_@babel+core@7.17.10 + '@babel/types': 7.17.10 dev: true /@babel/plugin-transform-react-pure-annotations/7.16.7_@babel+core@7.17.10: @@ -4285,6 +4344,25 @@ packages: '@babel/helper-plugin-utils': 7.16.7 dev: true + /@babel/plugin-transform-runtime/7.15.0: + resolution: {integrity: sha512-sfHYkLGjhzWTq6xsuQ01oEsUYjkHRux9fW1iUA68dC7Qd8BS1Unq4aZ8itmQp95zUzIcyR2EbNMTzAicFj+guw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/helper-module-imports': 7.16.0 + '@babel/helper-plugin-utils': 7.16.5 + babel-plugin-polyfill-corejs2: 0.2.3 + babel-plugin-polyfill-corejs3: 0.2.4 + babel-plugin-polyfill-regenerator: 0.2.3 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: false + /@babel/plugin-transform-runtime/7.15.0_@babel+core@7.17.10: resolution: {integrity: sha512-sfHYkLGjhzWTq6xsuQ01oEsUYjkHRux9fW1iUA68dC7Qd8BS1Unq4aZ8itmQp95zUzIcyR2EbNMTzAicFj+guw==} engines: {node: '>=6.9.0'} @@ -4742,7 +4820,7 @@ packages: '@babel/helper-plugin-utils': 7.16.7 '@babel/plugin-proposal-unicode-property-regex': 7.16.7_@babel+core@7.17.10 '@babel/plugin-transform-dotall-regex': 7.16.7_@babel+core@7.17.10 - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 esutils: 2.0.3 dev: true @@ -4758,7 +4836,7 @@ packages: '@babel/helper-plugin-utils': 7.16.7 '@babel/plugin-proposal-unicode-property-regex': 7.16.7_@babel+core@7.17.9 '@babel/plugin-transform-dotall-regex': 7.16.7_@babel+core@7.17.9 - '@babel/types': 7.17.0 + '@babel/types': 7.17.10 esutils: 2.0.3 dev: true @@ -4898,7 +4976,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.16.7 - '@babel/parser': 7.17.3 + '@babel/parser': 7.17.10 '@babel/types': 7.17.0 /@babel/traverse/7.16.10: @@ -10822,6 +10900,12 @@ packages: resolution: {integrity: sha1-7ihweulOEdK4J7y+UnC86n8+ce4=} dev: true + /@types/keyv/3.1.4: + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + dependencies: + '@types/node': 17.0.31 + dev: false + /@types/lodash-es/4.17.6: resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==} dependencies: @@ -10932,6 +11016,12 @@ packages: /@types/range-parser/1.2.4: resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} + /@types/react-avatar-editor/10.3.6: + resolution: {integrity: sha512-2r9+WYriqXIyjOTxy/DSiCUhr2TBiYD4Gzbyych1WUEwdPboNj0JRz9B5868TwiGgKD61jdWYyKy/OutHs7L6A==} + dependencies: + '@types/react': 18.0.9 + dev: false + /@types/react-avatar-editor/12.0.0: resolution: {integrity: sha512-835jSnCdQ2Kgrtz2sTYKMv9YSGJG0j2vd8C0sxWtYJoYOmYPs8nt25rTsbCOha3SVzWNiuw1TOQCVqN0iBQgrw==} dependencies: @@ -11021,6 +11111,12 @@ packages: '@types/node': 17.0.31 dev: true + /@types/responselike/1.0.0: + resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} + dependencies: + '@types/node': 17.0.31 + dev: false + /@types/retry/0.12.1: resolution: {integrity: sha512-xoDlM2S4ortawSWORYqsdU+2rxdh4LRW9ytc3zmT37RIKQh6IHyKwwtKhKis9ah8ol07DCkZxPt8BBvPjC6v4g==} dev: true @@ -12913,6 +13009,21 @@ packages: '@babel/core': 7.17.10 dev: true + /babel-plugin-polyfill-corejs2/0.2.3: + resolution: {integrity: sha512-NDZ0auNRzmAfE1oDDPW2JhzIMXUk+FFe2ICejmt5T4ocKgiQx3e0VCRx9NCAidcMtL2RUZaWtXnmjTCkx0tcbA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/compat-data': 7.16.8 + '@babel/helper-define-polyfill-provider': 0.2.4 + semver: 6.3.0 + transitivePeerDependencies: + - supports-color + dev: false + /babel-plugin-polyfill-corejs2/0.2.3_@babel+core@7.17.10: resolution: {integrity: sha512-NDZ0auNRzmAfE1oDDPW2JhzIMXUk+FFe2ICejmt5T4ocKgiQx3e0VCRx9NCAidcMtL2RUZaWtXnmjTCkx0tcbA==} peerDependencies: @@ -12971,7 +13082,7 @@ packages: dependencies: '@babel/core': 7.17.10 '@babel/helper-define-polyfill-provider': 0.1.5_@babel+core@7.17.10 - core-js-compat: 3.22.2 + core-js-compat: 3.22.4 transitivePeerDependencies: - supports-color dev: true @@ -12986,11 +13097,25 @@ packages: dependencies: '@babel/core': 7.17.9 '@babel/helper-define-polyfill-provider': 0.1.5_@babel+core@7.17.9 - core-js-compat: 3.22.2 + core-js-compat: 3.22.4 transitivePeerDependencies: - supports-color dev: true + /babel-plugin-polyfill-corejs3/0.2.4: + resolution: {integrity: sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/helper-define-polyfill-provider': 0.2.4 + core-js-compat: 3.20.1 + transitivePeerDependencies: + - supports-color + dev: false + /babel-plugin-polyfill-corejs3/0.2.4_@babel+core@7.17.10: resolution: {integrity: sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==} peerDependencies: @@ -13036,6 +13161,19 @@ packages: - supports-color dev: true + /babel-plugin-polyfill-regenerator/0.2.3: + resolution: {integrity: sha512-JVE78oRZPKFIeUqFGrSORNzQnrDwZR16oiWeGM8ZyjBn2XAT5OjP+wXx5ESuo33nUsFUEJYjtklnsKbxW5L+7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + dependencies: + '@babel/helper-define-polyfill-provider': 0.2.4 + transitivePeerDependencies: + - supports-color + dev: false + /babel-plugin-polyfill-regenerator/0.2.3_@babel+core@7.17.10: resolution: {integrity: sha512-JVE78oRZPKFIeUqFGrSORNzQnrDwZR16oiWeGM8ZyjBn2XAT5OjP+wXx5ESuo33nUsFUEJYjtklnsKbxW5L+7g==} peerDependencies: @@ -17048,7 +17186,7 @@ packages: resolution: {integrity: sha512-YNF+mZ/Wu2FU/gvmzuWtYc8rloubL7wfXCTgouFrnjGVXPA/EeYYA7pupXWrb3Iv1cTBeSSxxJIbK23l4MRNqg==} engines: {node: '>=8.3.0'} dependencies: - '@babel/traverse': 7.17.9 + '@babel/traverse': 7.17.10 '@babel/types': 7.17.0 c8: 7.11.2 transitivePeerDependencies: @@ -18718,6 +18856,8 @@ packages: resolution: {integrity: sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==} engines: {node: '>=4'} dependencies: + '@types/keyv': 3.1.4 + '@types/responselike': 1.0.0 decompress-response: 3.3.0 duplexer3: 0.1.4 get-stream: 3.0.0 @@ -18740,6 +18880,8 @@ packages: dependencies: '@sindresorhus/is': 0.14.0 '@szmarczak/http-timer': 1.1.2 + '@types/keyv': 3.1.4 + '@types/responselike': 1.0.0 cacheable-request: 6.1.0 decompress-response: 3.3.0 duplexer3: 0.1.4 @@ -24309,6 +24451,25 @@ packages: webpack: 4.46.0 dev: true + /react-avatar-editor/12.0.0: + resolution: {integrity: sha512-l7NrN8CXlUXbMhnbkuduAtR3+AlDz+DzYWlYBNr7q+rNeJMZlv26ap3Dk/D1WK/MPlnoS33iITlhLkuDsYQpug==} + peerDependencies: + react: ^0.14.0 || ^17.0.0 || 18 + react-dom: '>=0.14.0 || 18' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@babel/plugin-transform-runtime': 7.15.0 + '@babel/runtime': 7.16.5 + prop-types: 15.8.0 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: false + /react-avatar-editor/12.0.0_@babel+core@7.17.10: resolution: {integrity: sha512-l7NrN8CXlUXbMhnbkuduAtR3+AlDz+DzYWlYBNr7q+rNeJMZlv26ap3Dk/D1WK/MPlnoS33iITlhLkuDsYQpug==} peerDependencies: @@ -27050,6 +27211,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: + acorn: 8.7.1 commander: 2.20.3 source-map: 0.6.1 source-map-support: 0.5.21 @@ -27065,6 +27227,7 @@ packages: acorn: optional: true dependencies: + acorn: 8.7.1 commander: 2.20.3 source-map: 0.7.3 source-map-support: 0.5.21