diff --git a/cspell.json b/cspell.json index 423a72a0de85..43a55e4d3cd2 100644 --- a/cspell.json +++ b/cspell.json @@ -174,6 +174,7 @@ "popperjs", "porportion", "presale", + "pressable", "progressbar", "promi", "proxied", diff --git a/packages/injected-script/main/EventListenerPatch/hookInputUploadOnce.ts b/packages/injected-script/main/EventListenerPatch/hookInputUploadOnce.ts index 9d89f490c9f5..4b5e0fc6c022 100644 --- a/packages/injected-script/main/EventListenerPatch/hookInputUploadOnce.ts +++ b/packages/injected-script/main/EventListenerPatch/hookInputUploadOnce.ts @@ -7,7 +7,25 @@ const proto = HTMLInputElement.prototype const { defineProperty, deleteProperty } = Reflect const setTimeoutCaptured = setTimeout.bind(window) const clearTimeoutCaptured = clearTimeout.bind(window) -export function hookInputUploadOnce(...[format, fileName, fileArray]: InternalEvents['hookInputUploadOnce']) { + +/** + * This API can mock a file upload in React applications when injected script has been injected into the page. + * + * If the element is available, you can use the API like this: + * input.focus() + * hookInputUploadOnce(format, fileName, file, true) + * + * If the is dynamically generated after the user clicks "Upload" button on the web page, you can use the API like this: + * hookInputUploadOnce(format, fileName, file, false) + * uploadButton.click() + * @param format + * @param fileName + * @param fileArray + * @param triggerOnActiveElementNow + */ +export function hookInputUploadOnce( + ...[format, fileName, fileArray, triggerOnActiveElementNow]: InternalEvents['hookInputUploadOnce'] +) { let timer: ReturnType | null = null const e = new no_xray_Event('change', { bubbles: true, @@ -36,4 +54,8 @@ export function hookInputUploadOnce(...[format, fileName, fileArray]: InternalEv deleteProperty(this, 'files') }, 200) } + + if (triggerOnActiveElementNow && document.activeElement instanceof HTMLInputElement) { + document.activeElement?.click() + } } diff --git a/packages/injected-script/sdk/index.ts b/packages/injected-script/sdk/index.ts index 83a3e8a655d7..5c0b8e0d8afa 100644 --- a/packages/injected-script/sdk/index.ts +++ b/packages/injected-script/sdk/index.ts @@ -20,8 +20,13 @@ export function pasteInstagram(url: string) { export function inputText(text: string) { sendEvent('input', text) } -export function hookInputUploadOnce(format: string, fileName: string, image: Uint8Array) { - sendEvent('hookInputUploadOnce', format, fileName, Array.from(image)) +export function hookInputUploadOnce( + format: string, + fileName: string, + image: Uint8Array, + triggerOnActiveElementNow = false, +) { + sendEvent('hookInputUploadOnce', format, fileName, Array.from(image), triggerOnActiveElementNow) } document.addEventListener(CustomEventId, (e) => { diff --git a/packages/injected-script/shared/index.ts b/packages/injected-script/shared/index.ts index 7a6afd44f728..ab67094bf185 100644 --- a/packages/injected-script/shared/index.ts +++ b/packages/injected-script/shared/index.ts @@ -17,7 +17,7 @@ export interface InternalEvents { * How to use: * Call this event, then invoke the file selector (SNS). It will invoke click on some input, then let's replace with the result. */ - hookInputUploadOnce: [format: string, fileName: string, file: number[]] + hookInputUploadOnce: [format: string, fileName: string, file: number[], triggerOnActiveElementNow: boolean] // #region Eth inpage provider bridge /** Request the bridge to listen on an event. */ diff --git a/packages/mask/shared/kv-storage.ts b/packages/mask/shared/kv-storage.ts index 17ca63c7e079..a77cc4915102 100644 --- a/packages/mask/shared/kv-storage.ts +++ b/packages/mask/shared/kv-storage.ts @@ -12,6 +12,12 @@ const createInMemoryKVStorage = createKVStorageHost(inMemoryBackend, MaskMessage export const InMemoryStorages = { Plugin: createInMemoryKVStorage('plugin', {}), + FacebookNFTEventOnMobile: createInMemoryKVStorage('FacebookNFTEventOnMobile', { + userId: '', + avatarId: '', + address: '', + tokenId: '', + }), } export const PersistentStorages = { Plugin: createPersistentKVStorage('plugin', {}), diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide.tsx b/packages/mask/src/components/InjectedComponents/SetupGuide.tsx index 033f2076367f..81801277e8c4 100644 --- a/packages/mask/src/components/InjectedComponents/SetupGuide.tsx +++ b/packages/mask/src/components/InjectedComponents/SetupGuide.tsx @@ -480,8 +480,6 @@ function SetupGuideUI(props: SetupGuideUIProps) { let content = t('setup_guide_say_hello_content') if (ui.networkIdentifier === 'twitter.com') { content += t('setup_guide_say_hello_follow', { account: '@realMaskNetwork' }) - } else if (ui.networkIdentifier === 'facebook.com') { - content += t('setup_guide_say_hello_follow', { account: '@masknetwork' }) } ui.automation.maskCompositionDialog?.open?.(makeTypedMessageText(content), { diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx index 15204fd6bb6d..93a23fbbab07 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarButton.tsx @@ -30,6 +30,7 @@ const useStyles = makeStyles()((theme) => ({ interface NFTAvatarButtonProps extends withClasses<'root'> { onClick: () => void + showSetting?: boolean } export function NFTAvatarButton(props: NFTAvatarButtonProps) { @@ -40,7 +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/NFTAvatarRing.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarRing.tsx index e57002a35d68..db84a0b25882 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarRing.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTAvatarRing.tsx @@ -5,6 +5,16 @@ const useStyles = makeStyles()((theme) => ({ root: { overflow: 'unset', }, + container: { + boxShadow: '0 5px 15px rgba(0, 248, 255, 0.4), 0 10px 30px rgba(37, 41, 46, 0.2)', + transition: 'none', + borderRadius: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + lineHeight: 0, + border: '2px solid #00f8ff', + }, })) interface NFTAvatarRingProps { stroke: string @@ -14,11 +24,12 @@ interface NFTAvatarRingProps { price: string width: number id: string + hasRainbow?: boolean } export function NFTAvatarRing(props: NFTAvatarRingProps) { const { classes } = useStyles() - const { stroke, strokeWidth, fontSize, text, width, id, price } = props + const { stroke, strokeWidth, fontSize, text, width, id, price, hasRainbow = true } = props const avatarSize = width + 1 const R = avatarSize / 2 @@ -26,76 +37,65 @@ export function NFTAvatarRing(props: NFTAvatarRingProps) { const x1 = R - path_r const y1 = R const x2 = R + path_r - return ( - - - - - - - - - - - - - - - + + + - - - - - + + + + + + + + + + + + + + + + - - - - {text} - - - + + + + {text} + + + - - - - {price} - - - - - + + + + {price} + + + + ) + + return hasRainbow ? {svgNode} :
{svgNode}
} diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx index 05f8a7302545..126fe3933140 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadge.tsx @@ -22,10 +22,11 @@ interface NFTBadgeProps extends withClasses<'root' | 'text' | 'icon'> { avatar: AvatarMetaDB size?: number width?: number + hasRainbow?: boolean } export function NFTBadge(props: NFTBadgeProps) { - const { avatar, size = 140 } = props + const { avatar, size = 140, hasRainbow } = props const classes = useStylesExtends(useStyles(), props) const { value = { amount: '0', symbol: 'ETH', name: '', slug: '' }, loading } = useNFT( @@ -48,6 +49,7 @@ export function NFTBadge(props: NFTBadgeProps) { width={size} strokeWidth={14} stroke="black" + hasRainbow={hasRainbow} fontSize={9} text={ loading diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTimeline.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTimeline.tsx index f26e9d6ae0e9..c0fc9965f087 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTimeline.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTimeline.tsx @@ -4,17 +4,19 @@ import { MaskMessages } from '../../../utils' import { useNFTAvatar } from '../hooks' import type { AvatarMetaDB } from '../types' import { RainbowBox } from './RainbowBox' +import type { RSS3_KEY_SNS } from '../constants' interface NFTBadgeTimelineProps extends withClasses<'root'> { userId: string avatarId: string width: number height: number + snsKey: RSS3_KEY_SNS } export function NFTBadgeTimeline(props: NFTBadgeTimelineProps) { - const { userId, avatarId, width, height } = props - const { loading, value: _avatar } = useNFTAvatar(userId) + const { userId, avatarId, width, height, snsKey } = props + const { loading, value: _avatar } = useNFTAvatar(userId, snsKey) const [avatar, setAvatar] = useState() const [avatarId_, setAvatarId_] = useState('') diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTweet.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTweet.tsx index 20f5806671d1..be86dee58c6a 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTweet.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTBadgeTweet.tsx @@ -1,6 +1,7 @@ import { makeStyles } from '@masknet/theme' import { useLastRecognizedIdentity } from '../../../components/DataSource/useActivatedUI' import { NFTBadgeTimeline } from './NFTBadgeTimeline' +import type { RSS3_KEY_SNS } from '../constants' const useStyles = makeStyles()((theme) => ({ root: {}, @@ -10,12 +11,21 @@ interface NFTBadgeTweetProps { avatarId: string width: number height: number + snsKey: RSS3_KEY_SNS } export function NFTBadgeTweet(props: NFTBadgeTweetProps) { - const { avatarId, width, height } = props + const { avatarId, width, height, snsKey } = props const identity = useLastRecognizedIdentity() if (!identity?.identifier.userId) return null - return + return ( + + ) } diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTContractImage.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTContractImage.tsx index d52c67c7f283..1847b1f378fb 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTContractImage.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/NFTContractImage.tsx @@ -2,6 +2,7 @@ import { makeStyles } from '@masknet/theme' import { CircularProgress } from '@mui/material' import { useNFTAvatar } from '../hooks' import { useNFTVerified } from '../hooks/useNFTVerified' +import type { RSS3_KEY_SNS } from '../constants' const useStyles = makeStyles()((theme) => ({ root: {}, @@ -13,13 +14,14 @@ const useStyles = makeStyles()((theme) => ({ interface NFTContractImageProps { userId: string + snsKey: RSS3_KEY_SNS } export function NFTContractImage(props: NFTContractImageProps) { - const { userId } = props + const { userId, snsKey } = props const { classes } = useStyles() - const { loading: loadingNFT, value: NFTAvatar } = useNFTAvatar(userId) + const { loading: loadingNFT, value: NFTAvatar } = useNFTAvatar(userId, snsKey) const { loading, value: contract } = useNFTVerified(NFTAvatar?.address ?? '') return ( diff --git a/packages/mask/src/plugins/Avatar/SNSAdaptor/RainbowBox.tsx b/packages/mask/src/plugins/Avatar/SNSAdaptor/RainbowBox.tsx index dd7dd0244d46..51a71109b6fd 100644 --- a/packages/mask/src/plugins/Avatar/SNSAdaptor/RainbowBox.tsx +++ b/packages/mask/src/plugins/Avatar/SNSAdaptor/RainbowBox.tsx @@ -31,7 +31,6 @@ interface StyleProps { } const useStyles = makeStyles()((theme, { width, height, radius = '100%' }) => ({ root: { - transform: 'scale(0.97)', animation: `${rainbowBorderKeyFrames} 6s linear infinite`, width, height, diff --git a/packages/mask/src/plugins/Avatar/Services/index.ts b/packages/mask/src/plugins/Avatar/Services/index.ts index 9a6bc3470c8f..fbbfef876698 100644 --- a/packages/mask/src/plugins/Avatar/Services/index.ts +++ b/packages/mask/src/plugins/Avatar/Services/index.ts @@ -3,16 +3,19 @@ import type { AvatarMetaDB } from '../types' import { getNFTAvatarFromJSON } from './db' import { getUserAddress, setUserAddress } from './bind' import { getNFTAvatarFromRSS, saveNFTAvatarToRSS } from './rss3' +import type { RSS3_KEY_SNS } from '../constants' export async function getNFTAvatar( userId: string, network: string, + snsKey: RSS3_KEY_SNS, networkPluginId?: NetworkPluginID, chainId?: number, ) { const address = await getUserAddress(userId, network, networkPluginId, chainId) + if (address) { - return getNFTAvatarFromRSS(userId, address) + return getNFTAvatarFromRSS(userId, address, snsKey) } return getNFTAvatarFromJSON(userId) } @@ -21,11 +24,12 @@ export async function saveNFTAvatar( address: string, nft: AvatarMetaDB, network: string, + snsKey: RSS3_KEY_SNS, networkPluginId?: NetworkPluginID, chainId?: number, ) { try { - const avatar = await saveNFTAvatarToRSS(address, nft, '') + const avatar = await saveNFTAvatarToRSS(address, nft, '', snsKey) await setUserAddress(nft.userId, address, network, networkPluginId, chainId) return avatar } catch (error) { diff --git a/packages/mask/src/plugins/Avatar/Services/rss3.ts b/packages/mask/src/plugins/Avatar/Services/rss3.ts index 5f3a5aa2388d..f07d4c727d25 100644 --- a/packages/mask/src/plugins/Avatar/Services/rss3.ts +++ b/packages/mask/src/plugins/Avatar/Services/rss3.ts @@ -2,6 +2,7 @@ import { personalSign } from '../../../extension/background-script/EthereumServi import type { AvatarMetaDB } from '../types' import addSeconds from 'date-fns/addSeconds' import { RSS3 } from '@masknet/web3-providers' +import type { RSS3_KEY_SNS } from '../constants' interface NFTRSSNode { signature: string @@ -10,35 +11,40 @@ interface NFTRSSNode { const cache = new Map, number]>() -export async function getNFTAvatarFromRSS(userId: string, address: string) { - let v = cache.get(address) +export async function getNFTAvatarFromRSS(userId: string, address: string, snsKey: RSS3_KEY_SNS) { + const key = `${address}, ${userId}, ${snsKey}` + let v = cache.get(key) if (!v || Date.now() > v[1]) { - cache.set(address, [_getNFTAvatarFromRSS(userId, address), addSeconds(Date.now(), 60).getTime()]) + cache.set(key, [_getNFTAvatarFromRSS(userId, address, snsKey), addSeconds(Date.now(), 60).getTime()]) } - v = cache.get(address) + v = cache.get(key) const result = await v?.[0] return result?.nft } -async function _getNFTAvatarFromRSS(userId: string, address: string): Promise { +async function _getNFTAvatarFromRSS( + userId: string, + address: string, + snsKey: RSS3_KEY_SNS, +): Promise { const rss = RSS3.createRSS3(address, async (message: string) => { return personalSign(message, address) }) - const nfts = await RSS3.getFileData>(rss, address, '_nfts') + const nfts = await RSS3.getFileData>(rss, address, snsKey) if (nfts) { return nfts[userId] } return RSS3.getFileData(rss, address, '_nft') } -export async function saveNFTAvatarToRSS(address: string, nft: AvatarMetaDB, signature: string) { +export async function saveNFTAvatarToRSS(address: string, nft: AvatarMetaDB, signature: string, snsKey: RSS3_KEY_SNS) { const rss = RSS3.createRSS3(address, async (message: string) => { return personalSign(message, address) }) - let _nfts = await RSS3.getFileData>(rss, address, '_nfts') + let _nfts = await RSS3.getFileData>(rss, address, snsKey) if (!_nfts) { _nfts = { [nft.userId]: { signature, nft }, @@ -50,7 +56,7 @@ export async function saveNFTAvatarToRSS(address: string, nft: AvatarMetaDB, sig } } - await RSS3.setFileData(rss, address, '_nfts', _nfts) + await RSS3.setFileData(rss, address, snsKey, _nfts) // clear cache if (cache.has(address)) cache.delete(address) diff --git a/packages/mask/src/plugins/Avatar/constants.ts b/packages/mask/src/plugins/Avatar/constants.ts index ec2a44085c2a..40558eea6cdb 100644 --- a/packages/mask/src/plugins/Avatar/constants.ts +++ b/packages/mask/src/plugins/Avatar/constants.ts @@ -8,3 +8,8 @@ export const NFT_CONTRACT_JSON_VERIFIED_SERVER = 'https://configuration.r2d2.to/ export const PLUGIN_ID = PluginId.Avatar export const PLUGIN_NAME = 'Avatar' export const PLUGIN_DESCRIPTION = 'NFT Avatar' + +export enum RSS3_KEY_SNS { + TWITTER = '_nfts', + FACEBOOK = '_facebook_nfts', +} diff --git a/packages/mask/src/plugins/Avatar/hooks/useNFTAvatar.ts b/packages/mask/src/plugins/Avatar/hooks/useNFTAvatar.ts index 75d21fdef836..dfc223cefc4e 100644 --- a/packages/mask/src/plugins/Avatar/hooks/useNFTAvatar.ts +++ b/packages/mask/src/plugins/Avatar/hooks/useNFTAvatar.ts @@ -3,10 +3,11 @@ import type { AsyncState } from 'react-use/lib/useAsyncFn' import { activatedSocialNetworkUI } from '../../../social-network' import { PluginNFTAvatarRPC } from '../messages' import type { AvatarMetaDB } from '../types' +import type { RSS3_KEY_SNS } from '../constants' -export function useNFTAvatar(userId: string): AsyncState { +export function useNFTAvatar(userId: string, snsKey: RSS3_KEY_SNS): AsyncState { return useAsync(async () => { if (!userId || userId === '$unknown') return - return PluginNFTAvatarRPC.getNFTAvatar(userId, activatedSocialNetworkUI.networkIdentifier) + return PluginNFTAvatarRPC.getNFTAvatar(userId, activatedSocialNetworkUI.networkIdentifier, snsKey) }, [userId, activatedSocialNetworkUI.networkIdentifier]) } diff --git a/packages/mask/src/social-network-adaptor/facebook.com/collecting/identity.ts b/packages/mask/src/social-network-adaptor/facebook.com/collecting/identity.ts index a439e6dc25fe..38bb967fe7cf 100644 --- a/packages/mask/src/social-network-adaptor/facebook.com/collecting/identity.ts +++ b/packages/mask/src/social-network-adaptor/facebook.com/collecting/identity.ts @@ -1,9 +1,11 @@ import { LiveSelector, MutationObserverWatcher, ValueRef } from '@dimensiondev/holoflows-kit' -import type { SocialNetworkUI } from '../../../social-network/types' -import { creator } from '../../../social-network/utils' + +import { creator, SocialNetworkUI } from '../../../social-network' import { getProfileIdentifierAtFacebook, getUserID } from '../utils/getProfileIdentifier' import { isMobileFacebook } from '../utils/isMobile' -import { ProfileIdentifier } from '@masknet/shared-base' +import { delay, ProfileIdentifier } from '@masknet/shared-base' +import { searchAvatarSelector, searchUserIdOnMobileSelector } from '../utils/selector' +import { getAvatar, getBioDescription, getFacebookId, getNickName } from '../utils/user' export const IdentityProviderFacebook: SocialNetworkUI.CollectingCapabilities.IdentityResolveProvider = { hasDeprecatedPlaceholderName: true, @@ -36,6 +38,57 @@ function resolveLastRecognizedIdentityFacebookInner(ref: ValueRef, signal .then((id) => id && assign({ ...ref.value, identifier: new ProfileIdentifier('facebook.com', id) })) } +function resolveCurrentVisitingIdentityInner( + ref: SocialNetworkUI.CollectingCapabilities.IdentityResolveProvider['recognized'], + cancel: AbortSignal, +) { + const selector = isMobileFacebook ? searchUserIdOnMobileSelector() : searchAvatarSelector() + + const assign = async () => { + await delay(500) + const nickname = getNickName() + const bio = getBioDescription() + const handle = getFacebookId() + + const avatar = getAvatar() + + ref.value = { + identifier: handle ? new ProfileIdentifier('facebook.com', handle) : ProfileIdentifier.unknown, + nickname, + avatar, + bio, + } + } + + const createWatcher = (selector: LiveSelector) => { + const watcher = new MutationObserverWatcher(selector) + .addListener('onAdd', () => assign()) + .addListener('onChange', () => assign()) + .startWatch({ + childList: true, + subtree: true, + attributes: true, + }) + window.addEventListener('locationchange', assign) + cancel.addEventListener('abort', () => { + window.removeEventListener('locationchange', assign) + watcher.stopWatch() + }) + } + + assign() + + createWatcher(selector) +} + +export const CurrentVisitingIdentityProviderFacebook: SocialNetworkUI.CollectingCapabilities.IdentityResolveProvider = { + hasDeprecatedPlaceholderName: false, + recognized: creator.EmptyIdentityResolveProviderState(), + start(cancel) { + resolveCurrentVisitingIdentityInner(this.recognized, cancel) + }, +} + // #region LS // Try to resolve my identities const myUsernameLiveSelectorPC = new LiveSelector() diff --git a/packages/mask/src/social-network-adaptor/facebook.com/injection/Composition.tsx b/packages/mask/src/social-network-adaptor/facebook.com/injection/Composition.tsx index e3c84e8f8d0a..7ebdf51ce00b 100644 --- a/packages/mask/src/social-network-adaptor/facebook.com/injection/Composition.tsx +++ b/packages/mask/src/social-network-adaptor/facebook.com/injection/Composition.tsx @@ -37,16 +37,13 @@ if (isMobileFacebook) { .querySelector('[id="toolbarLabel"]') .closest(1) .querySelector('div:nth-child(2) > div:nth-child(4)') - } else + } else { composeBox = new LiveSelector() - .querySelector( - '[role="dialog"] form > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child > div:last-child > div:first-child', + .querySelectorAll( + '[role="dialog"] form > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child > div:last-child > div:first-child > div:last-child > div > div', ) - .querySelectorAll('[role="button"][tabindex="0"], [role="button"][tabindex="-1"]') - .map((x) => x.parentElement) .at(-2) - .map((x) => x.parentElement) - .map((x) => x.parentElement) + } } export function injectCompositionFacebook(signal: AbortSignal) { diff --git a/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarEditProfile.tsx b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarEditProfile.tsx new file mode 100644 index 000000000000..1280efe19771 --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarEditProfile.tsx @@ -0,0 +1,76 @@ +import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' +import { searchFacebookEditProfileSelector, searchFacebookProfileSettingButtonSelector } from '../../utils/selector' +import { createReactRootShadowed, startWatch } from '../../../../utils' +import { useLayoutEffect, useState } from 'react' +import { useLocationChange } from '../../../../utils/hooks/useLocationChange' +import { NFTAvatarButton } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatarButton' +import { makeStyles } from '@masknet/theme' + +export function injectOpenNFTAvatarEditProfileButton(signal: AbortSignal) { + const watcher = new MutationObserverWatcher(searchFacebookProfileSettingButtonSelector()) + startWatch(watcher, signal) + createReactRootShadowed(watcher.firstDOMProxy.beforeShadow, { signal }).render( + , + ) +} + +interface StyleProps { + minHeight: number + fontSize: number + marginTop: number + backgroundColor?: string +} + +const useStyles = makeStyles()((theme, props) => ({ + root: { + minHeight: props.minHeight, + fontSize: props.fontSize, + marginTop: props.marginTop, + backgroundColor: '#fff!important', + marginRight: theme.spacing(0.5), + marginLeft: theme.spacing(1.25), + borderRadius: '6px !important', + border: 'none !important', + color: '#050505!important', + }, +})) + +function OpenNFTAvatarEditProfileButtonInFaceBook() { + const [style, setStyle] = useState({ minHeight: 36, fontSize: 15, marginTop: 6 }) + const onClick = () => { + const editDom = searchFacebookEditProfileSelector().evaluate() + editDom?.click() + } + + const setStyleWithSelector = () => { + const editDom = searchFacebookProfileSettingButtonSelector().evaluate() + + if (!editDom) return + + const buttonDom = editDom.querySelector('a > div') + + if (!buttonDom) return + + const editCss = window.getComputedStyle(editDom) + const buttonCss = window.getComputedStyle(buttonDom) + + setStyle({ + fontSize: Number(editCss.fontSize.replace('px', '')), + marginTop: Number(editCss.paddingTop.replace('px', '')), + minHeight: 36, + backgroundColor: buttonCss.backgroundColor, + }) + } + + useLayoutEffect(() => { + setStyleWithSelector() + }, []) + + useLocationChange(() => { + setStyleWithSelector() + }) + + const { classes } = useStyles(style) + + return +} diff --git a/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarInFacebook.tsx b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarInFacebook.tsx new file mode 100644 index 000000000000..32178c234dbb --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarInFacebook.tsx @@ -0,0 +1,184 @@ +import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' +import { searchFacebookAvatarOnMobileSelector, searchFacebookAvatarSelector } from '../../utils/selector' +import { createReactRootShadowed, MaskMessages, startWatch } from '../../../../utils' +import { useEffect, useMemo, useState } from 'react' +import type { NFTAvatarEvent } from '@masknet/shared-base' +import { max, pickBy } from 'lodash-unified' +import { useCurrentVisitingIdentity } from '../../../../components/DataSource/useActivatedUI' +import { useAsync, useLocation, useWindowSize } from 'react-use' +import { useWallet } from '@masknet/plugin-infra' +import type { AvatarMetaDB } from '../../../../plugins/Avatar/types' +import { PluginNFTAvatarRPC } from '../../../../plugins/Avatar/messages' +import { getAvatarId } from '../../utils/user' +import { useNFTAvatar } from '../../../../plugins/Avatar/hooks' +import { NFTBadge } from '../../../../plugins/Avatar/SNSAdaptor/NFTBadge' +import { makeStyles } from '@masknet/theme' +import { isMobileFacebook } from '../../utils/isMobile' +import { InMemoryStorages } from '../../../../../shared' +import { RSS3_KEY_SNS } from '../../../../plugins/Avatar/constants' + +export function injectNFTAvatarInFacebook(signal: AbortSignal) { + const watcher = new MutationObserverWatcher(searchFacebookAvatarSelector()) + if (!isMobileFacebook) { + startWatch(watcher, signal) + createReactRootShadowed(watcher.firstDOMProxy.afterShadow, { signal }).render() + return + } + + // mobile + const mobileWatcher = new MutationObserverWatcher(searchFacebookAvatarOnMobileSelector()) + startWatch(mobileWatcher, signal) + createReactRootShadowed(mobileWatcher.firstDOMProxy.afterShadow, { signal }).render() +} + +const useStyles = makeStyles()(() => ({ + root: { + position: 'absolute', + textAlign: 'center', + color: 'white', + width: '100%', + height: '100%', + top: 0, + left: 0, + }, + text: { + fontSize: '20px !important', + fontWeight: 700, + }, + icon: { + width: '19px !important', + height: '19px !important', + }, +})) + +const clearStorages = () => { + InMemoryStorages.FacebookNFTEventOnMobile.storage.userId.setValue('') + InMemoryStorages.FacebookNFTEventOnMobile.storage.address.setValue('') + InMemoryStorages.FacebookNFTEventOnMobile.storage.tokenId.setValue('') +} + +function NFTAvatarInFacebook() { + const { classes } = useStyles() + const wallet = useWallet() + const [avatar, setAvatar] = useState() + const identity = useCurrentVisitingIdentity() + const location = useLocation() + const { value: _avatar } = useNFTAvatar(identity.identifier.userId, RSS3_KEY_SNS.FACEBOOK) + + const [NFTEvent, setNFTEvent] = useState() + + const windowSize = useWindowSize() + const showAvatar = useMemo(() => { + if (isMobileFacebook) { + const node = searchFacebookAvatarOnMobileSelector().closest(1).evaluate() + + if (node) { + node.style.position = 'relative' + } + } + return getAvatarId(identity.avatar ?? '') === avatar?.avatarId + }, [avatar?.avatarId, identity.avatar, isMobileFacebook]) + + const size = useMemo(() => { + const ele = isMobileFacebook + ? searchFacebookAvatarOnMobileSelector().evaluate() + : searchFacebookAvatarSelector().evaluate() + if (ele) { + const style = window.getComputedStyle(ele) + return max([148, Number.parseInt(style.width.replace('px', '') ?? 0, 10)]) + } + return 0 + }, [windowSize, isMobileFacebook, avatar]) + + useEffect(() => { + return MaskMessages.events.NFTAvatarUpdated.on((data) => + setNFTEvent((prev) => { + if (!prev) return data + return { ...prev, ...pickBy(data, (item) => !!item) } + }), + ) + }, []) + + // Because of the mobile upload step, need to use memory storage to store NFTEven + useAsync(async () => { + const storages = InMemoryStorages.FacebookNFTEventOnMobile.storage + + if (!wallet) return + if (NFTEvent?.address && NFTEvent?.tokenId && NFTEvent?.avatarId) { + try { + const avatarInfo = await PluginNFTAvatarRPC.saveNFTAvatar( + wallet.address, + { ...NFTEvent, avatarId: getAvatarId(identity.avatar ?? '') } as AvatarMetaDB, + identity.identifier.network, + RSS3_KEY_SNS.FACEBOOK, + ) + if (!avatarInfo) { + setNFTEvent(undefined) + setAvatar(undefined) + window.alert('Sorry, failed to save NFT Avatar. Please set again.') + return + } + + setAvatar(avatarInfo) + + setNFTEvent(undefined) + } catch (error: any) { + setNFTEvent(undefined) + setAvatar(undefined) + window.alert(error.message) + return + } + } else if (storages.address.value && storages.userId.value && storages.tokenId.value) { + try { + const avatarInfo = await PluginNFTAvatarRPC.saveNFTAvatar( + wallet.address, + { + userId: storages.userId.value, + tokenId: storages.tokenId.value, + address: storages.address.value, + avatarId: getAvatarId(identity.avatar ?? ''), + } as AvatarMetaDB, + identity.identifier.network, + RSS3_KEY_SNS.FACEBOOK, + ) + if (!avatarInfo) { + clearStorages() + setAvatar(undefined) + window.alert('Sorry, failed to save NFT Avatar. Please set again.') + return + } + setAvatar(avatarInfo) + clearStorages() + } catch (error: any) { + clearStorages() + setAvatar(undefined) + window.alert(error.message) + return + } + } + }, [identity.avatar]) + + useEffect(() => setAvatar(_avatar), [_avatar, location]) + + // #region clear white border + useEffect(() => { + const node = searchFacebookAvatarSelector().closest(3).evaluate() + if (!node) return + if (showAvatar) { + node.setAttribute('style', 'padding: 0px') + } else { + node.removeAttribute('style') + } + }, [showAvatar]) + // #endregion + + if (!avatar || !size || !showAvatar) return null + + return ( + + ) +} diff --git a/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarInTimeline.tsx b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarInTimeline.tsx new file mode 100644 index 000000000000..0f20cd7d4fdd --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/NFTAvatarInTimeline.tsx @@ -0,0 +1,75 @@ +import { DOMProxy, LiveSelector, MutationObserverWatcher } from '@dimensiondev/holoflows-kit' +import { createReactRootShadowed, startWatch } from '../../../../utils' +import { getInjectNodeInfo } from '../../utils/avatar' +import { searchFaceBookPostAvatarOnMobileSelector, searchFaceBookPostAvatarSelector } from '../../utils/selector' +import { Flags } from '../../../../../shared' +import { NFTBadgeTimeline } from '../../../../plugins/Avatar/SNSAdaptor/NFTBadgeTimeline' +import { isMobileFacebook } from '../../utils/isMobile' +import { RSS3_KEY_SNS } from '../../../../plugins/Avatar/constants' + +function getFacebookId(element: HTMLElement | SVGElement) { + const node = (isMobileFacebook ? element.firstChild : element.parentNode?.parentNode) as HTMLLinkElement + if (!node) return '' + const url = new URL(node.href) + + if (url.pathname === '/profile.php' && url.searchParams.get('id')) + return url.searchParams.get(isMobileFacebook ? 'lst' : 'id') + + return url.pathname.replace('/', '') +} + +function _(selector: () => LiveSelector, signal: AbortSignal) { + startWatch( + new MutationObserverWatcher(selector()).useForeach((element, key) => { + let remove = () => {} + + const run = async () => { + const facebookId = getFacebookId(element) + + if (!facebookId) return + + const info = getInjectNodeInfo(element) + + if (!info) return + + const proxy = DOMProxy({ afterShadowRootInit: { mode: Flags.using_ShadowDOM_attach_mode } }) + proxy.realCurrent = info.element + + const root = createReactRootShadowed(proxy.afterShadow, { signal }) + + root.render( +
+ +
, + ) + + remove = root.destory + } + + run() + + return { + onNodeMutation: run, + onTargetChanged: run, + onRemove: () => remove(), + } + }), + signal, + ) +} + +export async function injectUserNFTAvatarAtFacebook(signal: AbortSignal) { + _(isMobileFacebook ? searchFaceBookPostAvatarOnMobileSelector : searchFaceBookPostAvatarSelector, signal) +} diff --git a/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/ProfileNFTAvatar.tsx b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/ProfileNFTAvatar.tsx new file mode 100644 index 000000000000..81fd1b9818fd --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/injection/NFT/ProfileNFTAvatar.tsx @@ -0,0 +1,158 @@ +import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' +import { makeStyles } from '@masknet/theme' +import { createReactRootShadowed, MaskMessages, startWatch } from '../../../../utils' +import { NFTAvatar } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatar' +import { blobToArrayBuffer } from '@dimensiondev/kit' +import { hookInputUploadOnce } from '@masknet/injected-script' +import { + searchFacebookAvatarListSelector, + searchFacebookAvatarMobileListSelector, + searchFacebookAvatarOpenFilesOnMobileSelector, + searchFacebookAvatarOpenFilesSelector, + searchFacebookConfirmAvatarImageSelector, + searchFacebookSaveAvatarButtonSelector, +} from '../../utils/selector' +import { useCallback, useEffect } from 'react' +import { toPNG } from '../../../../plugins/Avatar/utils' +import type { ERC721TokenDetailed } from '@masknet/web3-shared-evm' +import { useCurrentVisitingIdentity } from '../../../../components/DataSource/useActivatedUI' +import { getAvatarId } from '../../utils/user' +import { isMobileFacebook } from '../../utils/isMobile' +import { InMemoryStorages } from '../../../../../shared' + +export async function injectProfileNFTAvatarInFaceBook(signal: AbortSignal) { + if (!isMobileFacebook) { + // The first step in setting an avatar + const watcher = new MutationObserverWatcher(searchFacebookAvatarListSelector()) + startWatch(watcher, signal) + createReactRootShadowed(watcher.firstDOMProxy.afterShadow, { signal }).render() + + // The second step in setting an avatar + const saveButtonWatcher = new MutationObserverWatcher(searchFacebookSaveAvatarButtonSelector()).useForeach( + (node, key, proxy) => { + const root = createReactRootShadowed(proxy.afterShadow, { signal }) + root.render() + return () => root.destory() + }, + ) + + startWatch(saveButtonWatcher, signal) + } + const watcher = new MutationObserverWatcher(searchFacebookAvatarMobileListSelector()) + startWatch(watcher, signal) + createReactRootShadowed(watcher.firstDOMProxy.afterShadow, { signal }).render() +} + +const useStyles = makeStyles()((theme) => ({ + root: { + padding: '8px 0', + margin: '0 16px', + }, +})) + +async function changeImageToActiveElements(image: File | Blob): Promise { + const imageBuffer = await blobToArrayBuffer(image) + hookInputUploadOnce('image/png', 'avatar.png', new Uint8Array(imageBuffer)) + searchFacebookAvatarOpenFilesSelector().evaluate()?.click() +} + +function NFTAvatarInFacebookFirstStep() { + const { classes } = useStyles() + + const identity = useCurrentVisitingIdentity() + + const onChange = useCallback( + async (token: ERC721TokenDetailed) => { + if (!token.info.imageURL) return + const image = await toPNG(token.info.imageURL) + if (!image) return + + await changeImageToActiveElements(image) + + MaskMessages.events.NFTAvatarUpdated.sendToLocal({ + userId: identity.identifier.userId, + avatarId: '', + address: token.contractDetailed.address, + tokenId: token.tokenId, + }) + }, + [identity], + ) + + return +} + +function NFTAvatarInFacebookSecondStep() { + useEffect(() => { + const save = searchFacebookSaveAvatarButtonSelector().evaluate().at(0) + if (!save) return + const handler = () => { + const image = searchFacebookConfirmAvatarImageSelector().evaluate() + if (!image) return + + const imageURL = image.getAttribute('src') + if (!imageURL) return + + const avatarId = getAvatarId(imageURL) + if (avatarId) { + MaskMessages.events.NFTAvatarUpdated.sendToLocal({ + userId: '', + address: '', + tokenId: '', + avatarId, + }) + } + } + + save.addEventListener('click', handler) + + return () => save.removeEventListener('click', handler) + }, []) + return null +} + +async function changeImageToActiveElementsOnMobile(image: File | Blob): Promise { + const imageBuffer = await blobToArrayBuffer(image) + + const input = searchFacebookAvatarOpenFilesOnMobileSelector().evaluate() + + if (input) { + input.style.visibility = 'unset' + input.focus() + hookInputUploadOnce('image/png', 'avatar.png', new Uint8Array(imageBuffer), true) + input.style.visibility = 'hidden' + + const file = new File([image], 'avatar.png', { type: 'image/png', lastModified: Date.now() }) + const container = new DataTransfer() + container.items.add(file) + input.files = container.files + } +} + +const useMobileStyles = makeStyles()({ + root: { + backgroundColor: '#ffffff', + }, +}) + +function NFTAvatarListInFaceBookMobile() { + const { classes } = useMobileStyles() + const identity = useCurrentVisitingIdentity() + + const onChange = useCallback( + async (token: ERC721TokenDetailed) => { + if (!token.info.imageURL) return + const image = await toPNG(token.info.imageURL) + if (!image) return + + await changeImageToActiveElementsOnMobile(image) + + InMemoryStorages.FacebookNFTEventOnMobile.storage.userId.setValue(identity.identifier.userId) + InMemoryStorages.FacebookNFTEventOnMobile.storage.address.setValue(token.contractDetailed.address) + InMemoryStorages.FacebookNFTEventOnMobile.storage.tokenId.setValue(token.tokenId) + }, + [identity], + ) + + return +} diff --git a/packages/mask/src/social-network-adaptor/facebook.com/ui-provider.ts b/packages/mask/src/social-network-adaptor/facebook.com/ui-provider.ts index 8d81c39aab86..ee64a7ce474e 100644 --- a/packages/mask/src/social-network-adaptor/facebook.com/ui-provider.ts +++ b/packages/mask/src/social-network-adaptor/facebook.com/ui-provider.ts @@ -5,7 +5,7 @@ import { facebookShared } from './shared' import { getProfilePageUrlAtFacebook } from './utils/parse-username' import { taskOpenComposeBoxFacebook } from './automation/openComposeBox' import { pasteTextToCompositionFacebook } from './automation/pasteTextToComposition' -import { IdentityProviderFacebook } from './collecting/identity' +import { CurrentVisitingIdentityProviderFacebook, IdentityProviderFacebook } from './collecting/identity' import { InitAutonomousStateFriends } from '../../social-network/defaults/state/InitFriends' import { InitAutonomousStateProfiles } from '../../social-network/defaults/state/InitProfiles' import { injectCompositionFacebook } from './injection/Composition' @@ -25,6 +25,10 @@ import { makeStyles } from '@masknet/theme' import { ProfileIdentifier } from '@masknet/shared-base' import { globalUIState } from '../../social-network' import { injectToolboxHintAtFacebook as injectToolboxAtFacebook } from './injection/Toolbar' +import { injectProfileNFTAvatarInFaceBook } from './injection/NFT/ProfileNFTAvatar' +import { injectNFTAvatarInFacebook } from './injection/NFT/NFTAvatarInFacebook' +import { injectUserNFTAvatarAtFacebook } from './injection/NFT/NFTAvatarInTimeline' +import { injectOpenNFTAvatarEditProfileButton } from './injection/NFT/NFTAvatarEditProfile' const useInjectedDialogClassesOverwriteFacebook = makeStyles()((theme) => { const smallQuery = `@media (max-width: ${theme.breakpoints.values.sm}px)` @@ -129,6 +133,7 @@ const facebookUI: SocialNetworkUI.Definition = { }, collecting: { identityProvider: IdentityProviderFacebook, + currentVisitingIdentityProvider: CurrentVisitingIdentityProviderFacebook, postsProvider: PostProviderFacebook, }, customization: { @@ -178,6 +183,10 @@ const facebookUI: SocialNetworkUI.Definition = { }, ), }, + userAvatar: injectUserNFTAvatarAtFacebook, + enhancedProfileNFTAvatar: injectProfileNFTAvatarInFaceBook, + profileAvatar: injectNFTAvatarInFacebook, + openNFTAvatar: injectOpenNFTAvatarEditProfileButton, postInspector: injectPostInspectorFacebook, pageInspector: injectPageInspectorDefault(), setupWizard: createTaskStartSetupGuideDefault(), diff --git a/packages/mask/src/social-network-adaptor/facebook.com/utils/avatar.ts b/packages/mask/src/social-network-adaptor/facebook.com/utils/avatar.ts new file mode 100644 index 000000000000..37cfab754c0f --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/utils/avatar.ts @@ -0,0 +1,37 @@ +import { getAvatarId } from './user' +import { isMobileFacebook } from './isMobile' + +export function getInjectNodeInfo(element: HTMLElement | SVGElement) { + if (!isMobileFacebook) { + const imgEle = element.querySelector('image') + if (!imgEle) return + + const nftDom = imgEle.parentNode?.parentNode as HTMLElement + + const width = Number(window.getComputedStyle(nftDom).width.replace('px', '') ?? 0) + const height = Number(window.getComputedStyle(nftDom).height.replace('px', '') ?? 0) + + const avatarId = getAvatarId(imgEle.href.baseVal) + + if (!avatarId) return + + return { element: nftDom, width, height, avatarId } + } + + const imgEle = element.querySelector('i') + + if (!imgEle) return + + const nftDom = imgEle.parentNode?.parentNode as HTMLElement + + const containerDom = imgEle.parentNode as HTMLElement + + const width = Number(window.getComputedStyle(nftDom).width.replace('px', '') ?? 0) + const height = Number(window.getComputedStyle(nftDom).height.replace('px', '') ?? 0) + + const avatarId = getAvatarId(imgEle.style.background.match(/\(["']?(.*?)["']?\)/)?.[1] ?? '') + + if (!avatarId) return + + return { element: containerDom, width, height, avatarId } +} diff --git a/packages/mask/src/social-network-adaptor/facebook.com/utils/getProfileIdentifier.ts b/packages/mask/src/social-network-adaptor/facebook.com/utils/getProfileIdentifier.ts index 5b1bf6b550a2..5e36129a8491 100644 --- a/packages/mask/src/social-network-adaptor/facebook.com/utils/getProfileIdentifier.ts +++ b/packages/mask/src/social-network-adaptor/facebook.com/utils/getProfileIdentifier.ts @@ -2,6 +2,7 @@ import { ProfileIdentifier } from '@masknet/shared-base' import Services from '../../../extension/service' import type { Profile } from '../../../database' import { getCurrentIdentifier } from '../../utils' +import { searchUserIdOnMobileSelector } from './selector' type link = HTMLAnchorElement | null | undefined @@ -83,6 +84,21 @@ export function getUserID(x: string) { const url = relative ? new URL(x, location.host) : new URL(x) if (url.hostname !== 'www.facebook.com' && url.hostname !== 'm.facebook.com') return null + + // Get the userId from the meta element + if (url.hostname === 'm.facebook.com') { + const node = searchUserIdOnMobileSelector().evaluate() + if (!node) return null + + const href = node.getAttribute('href') + + if (!href) return null + + const match = href.match(/lst=(\w+)/) + if (!match) return null + + return match[1] + } if (url.pathname.endsWith('.php')) { if (!url.search) return null const search = new URLSearchParams(url.search) 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 new file mode 100644 index 000000000000..0e2c193b6e72 --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/utils/selector.ts @@ -0,0 +1,82 @@ +import { LiveSelector } from '@dimensiondev/holoflows-kit' + +type E = HTMLElement + +const querySelector = (selector: string, singleMode = true) => { + const ls = new LiveSelector().querySelector(selector) + return (singleMode ? ls.enableSingleMode() : ls) as LiveSelector +} + +const querySelectorAll = (selector: string) => { + return new LiveSelector().querySelectorAll(selector) +} + +export const searchUserIdOnMobileSelector: () => LiveSelector = () => + querySelector('div[data-sigil$="profile"] a') + +export const searchAvatarSelector: () => LiveSelector = () => + querySelector('[role="link"] [role="img"] image, [role="button"] [role="img"] image') + +export const searchNickNameSelector: () => LiveSelector = () => + querySelector('span[dir="auto"] div h1') + +export const searchNickNameSelectorOnMobile: () => LiveSelector = () => querySelector('#cover-name-root h3') + +export const bioDescriptionSelector = () => + querySelector('span[dir="auto"] div h1').closest(7).querySelector('span[dir="auto"] > span') + +export const bioDescriptionSelectorOnMobile: () => LiveSelector = () => querySelector('#bio div') + +export const searchUserIdSelector: () => LiveSelector = () => + querySelector('div[data-pagelet="ProfileTabs"] a[role="tab"]') + +export const searchUserIdSelectorOnMobile: () => LiveSelector = () => + querySelector('#tlFeed div[data-sigil="scroll-area"] a:last-child') + +// #endregion facebook nft avatar + +export const searchFacebookAvatarListSelector = () => + querySelector('[role="dialog"] > div > div > div > input[type=file] + [role="button"]') + .closest(3) + .querySelector('div') + +export const searchFacebookAvatarMobileListSelector = () => querySelector('#nuxChoosePhotoButton').closest(6) + +export const searchFacebookAvatarSelector = () => + querySelector('[role="button"] svg[role="img"], [role="link"] svg[role="img"]') + +export const searchFacebookAvatarOnMobileSelector = () => + querySelector('[data-sigil="timeline-cover"] i[aria-label$="picture"]') + +export const searchFaceBookPostAvatarSelector = () => + new LiveSelector().querySelectorAll('[type="nested/pressable"] svg') + +export const searchFaceBookPostAvatarOnMobileSelector = () => querySelectorAll('[data-gt=\'{"tn":"~"}\']') + +export const searchFacebookAvatarOpenFilesSelector = () => querySelector('[role="dialog"] input[type=file] ~ div') + +export const searchFacebookAvatarOpenFilesOnMobileSelector = () => + querySelector('#nuxChoosePhotoButton ~ input') + +export const searchFacebookSaveAvatarButtonOnMobileSelector = () => querySelector('#nuxUploadPhotoButton') + +export const searchFacebookAvatarContainerSelector = () => + querySelector('div[data-pagelet="ProfileActions"] > div > div') + +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"]') + +export const searchFacebookSaveAvatarButtonSelector = () => + new LiveSelector() + .querySelector('[role="dialog"] [role="slider"]') + .closest(7) + .querySelectorAll('div') + .map((x) => x.parentElement?.parentElement) + .at(-1) + +export const searchFacebookConfirmAvatarImageSelector = () => + querySelector('[role="dialog"] [role="slider"]').closest(7).querySelector('img') +// #region diff --git a/packages/mask/src/social-network-adaptor/facebook.com/utils/user.ts b/packages/mask/src/social-network-adaptor/facebook.com/utils/user.ts new file mode 100644 index 000000000000..7cdf949642de --- /dev/null +++ b/packages/mask/src/social-network-adaptor/facebook.com/utils/user.ts @@ -0,0 +1,68 @@ +import { + bioDescriptionSelector, + bioDescriptionSelectorOnMobile, + searchAvatarSelector, + searchFacebookAvatarOnMobileSelector, + searchNickNameSelector, + searchNickNameSelectorOnMobile, + searchUserIdSelector, + searchUserIdSelectorOnMobile, +} from './selector' +import { collectNodeText } from '../../../utils' +import { isMobileFacebook } from './isMobile' + +export const getNickName = () => { + const node = isMobileFacebook ? searchNickNameSelectorOnMobile().evaluate() : searchNickNameSelector().evaluate() + if (!node) return '' + + return collectNodeText(node) +} + +export const getAvatar = () => { + const node = isMobileFacebook + ? searchFacebookAvatarOnMobileSelector().evaluate() + : searchAvatarSelector().evaluate() + if (!node) return + + const imageURL = + (isMobileFacebook + ? node.style.background.match(/\(["']?(.*?)["']?\)/)?.[1] + : node.getAttribute('xlink:href')) ?? '' + + return imageURL.trim() +} + +export const getBioDescription = () => { + const node = isMobileFacebook ? bioDescriptionSelectorOnMobile().evaluate() : bioDescriptionSelector().evaluate() + + if (!node) return '' + + // Only collect text + return [...node.childNodes] + .map((each) => { + if (each.nodeType === document.TEXT_NODE) return (each as Text).nodeValue || '' + return '' + }) + .join('') +} + +export const getFacebookId = () => { + const node = isMobileFacebook ? searchUserIdSelectorOnMobile().evaluate() : searchUserIdSelector().evaluate() + + if (!node) return '' + const url = new URL(node.href) + + if (url.pathname === '/profile.php') return url.searchParams.get(isMobileFacebook ? 'lst' : 'id') + + return url.pathname.replaceAll('/', '') +} + +const FACEBOOK_AVATAR_ID_MATCH = /(\w+).(?:png|jpg|gif|bmp)/ + +export const getAvatarId = (avatarURL: string) => { + if (!avatarURL) return '' + const _url = new URL(avatarURL) + const match = _url.pathname.match(FACEBOOK_AVATAR_ID_MATCH) + if (!match) return '' + return match[1] +} diff --git a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/Avatar.tsx b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/Avatar.tsx index 8c952e3fb8f0..df94236ace70 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/Avatar.tsx +++ b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/Avatar.tsx @@ -5,14 +5,14 @@ import { Flags } from '../../../../../shared' import { getInjectNodeInfo } from '../../utils/avatar' import { postAvatarsContentSelector } from '../../utils/selector' import { NFTAvatarMiniClip } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatarClip' +import { RSS3_KEY_SNS } from '../../../../plugins/Avatar/constants' function getTwitterId(ele: HTMLElement) { const twitterIdNode = (ele.firstChild?.nextSibling as HTMLElement).querySelector( '[dir="ltr"] > span', ) as HTMLSpanElement if (!twitterIdNode) return - const twitterId = twitterIdNode.innerText.trim().replace('@', '') - return twitterId + return twitterIdNode.innerText.trim().replace('@', '') } function _(main: () => LiveSelector, signal: AbortSignal) { @@ -48,6 +48,7 @@ function _(main: () => LiveSelector, signal: AbortSignal) { avatarId={info.avatarId} width={info.width - 4} height={info.height - 4} + snsKey={RSS3_KEY_SNS.TWITTER} /> )} , 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 6274a9451929..5042b59cd486 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 @@ -2,7 +2,7 @@ import { createReactRootShadowed, MaskMessages, NFTAvatarEvent, startWatch } fro import { searchTwitterAvatarLinkSelector, searchTwitterAvatarSelector } from '../../utils/selector' import { MutationObserverWatcher } from '@dimensiondev/holoflows-kit' import { makeStyles } from '@masknet/theme' -import { useState, useEffect, useMemo, useRef } from 'react' +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' @@ -14,6 +14,7 @@ import { NFTAvatar } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatar' import { useAsync, useLocation, useUpdateEffect, useWindowSize } from 'react-use' import { rainbowBorderKeyFrames } from '../../../../plugins/Avatar/SNSAdaptor/RainbowBox' import { trim } from 'lodash-unified' +import { RSS3_KEY_SNS } from '../../../../plugins/Avatar/constants' export function injectNFTAvatarInTwitter(signal: AbortSignal) { const watcher = new MutationObserverWatcher(searchTwitterAvatarSelector()) @@ -50,7 +51,7 @@ function NFTAvatarInTwitter() { const borderElement = useRef() const identity = useCurrentVisitingIdentity() const wallet = useWallet() - const { value: _avatar } = useNFTAvatar(identity.identifier.userId) + const { value: _avatar } = useNFTAvatar(identity.identifier.userId, RSS3_KEY_SNS.TWITTER) const [avatar, setAvatar] = useState() const windowSize = useWindowSize() const location = useLocation() @@ -98,6 +99,7 @@ function NFTAvatarInTwitter() { avatarId: getAvatarId(identity.avatar ?? ''), } as AvatarMetaDB, identity.identifier.network, + RSS3_KEY_SNS.TWITTER, ).catch((error) => { setNFTEvent(undefined) setAvatar(undefined) @@ -207,6 +209,7 @@ function NFTAvatarInTwitter() { <> {showAvatar ? ( LiveSelector, signal: AbortSignal) { startWatch( @@ -27,7 +28,7 @@ function _(main: () => LiveSelector, signal: AbortSignal) { const root = createReactRootShadowed(proxy.afterShadow, { signal }) root.render(
- +
, ) remover = root.destory 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 index 643107f7359d..d4d9edf6afea 100644 --- 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 @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { blobToArrayBuffer } from '@dimensiondev/kit' import { createReactRootShadowed, MaskMessages, NFTAvatarEvent, startWatch } from '../../../../utils' import { - searchAvatarOpenFileSelector, + searchTwitterAvatarOpenFilesSelector, searchProfessionalButtonSelector, searchProfileSaveSelector, searchProfileSetAvatarSelector, @@ -58,12 +58,13 @@ function getStyles() { async function changeImageToActiveElements(image: File | Blob): Promise { const imageBuffer = await blobToArrayBuffer(image) hookInputUploadOnce('image/png', 'avatar.png', new Uint8Array(imageBuffer)) - ;(searchAvatarOpenFileSelector().evaluate()[0]?.parentElement?.children[0] as HTMLElement)?.click() + ;(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) => { diff --git a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/TweetNFTAvatar.tsx b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/TweetNFTAvatar.tsx index 1f355a6e56d9..7c07bf1c29da 100644 --- a/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/TweetNFTAvatar.tsx +++ b/packages/mask/src/social-network-adaptor/twitter.com/injection/NFT/TweetNFTAvatar.tsx @@ -5,6 +5,7 @@ import { Flags } from '../../../../../shared' import { getInjectNodeInfo } from '../../utils/avatar' import { searchRetweetAvatarSelector, searchTweetAvatarSelector } from '../../utils/selector' import { NFTAvatarMiniClip } from '../../../../plugins/Avatar/SNSAdaptor/NFTAvatarClip' +import { RSS3_KEY_SNS } from '../../../../plugins/Avatar/constants' function _(main: () => LiveSelector, signal: AbortSignal) { startWatch( @@ -24,7 +25,12 @@ function _(main: () => LiveSelector, signal: AbortSignal) { {info.isTwitterNFT ? ( ) : ( - + )} , ) 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 9459c06de3eb..3306e6b4f213 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 @@ -197,7 +197,7 @@ export const selfInfoSelectors = () => ({ userAvatar: p(avatar, 1), }) -// #region nft avatar +// #region twitter nft avatar export const searchProfileAvatarSelector = () => { return querySelectorAll('[data-testid="fileInput"]').at(1).closest(4) } @@ -217,7 +217,7 @@ export const searchAvatarSelectorImage = () => .closest(2) .querySelector('div > div > :nth-child(2) > div > img') -export const searchAvatarOpenFileSelector = () => querySelectorAll('[data-testid="fileInput"]').at(1) +export const searchTwitterAvatarOpenFilesSelector = () => querySelectorAll('[data-testid="fileInput"]').at(1) export const searchProfileSaveSelector = () => querySelector('[data-testid="Profile_Save_Button"]') export const searchProfessionalButtonSelector = () => querySelector('[data-testid*="ProfessionalButton"]')