From 787a88101c6f50a2c87584b8fe2b462afc5f0815 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sat, 25 Apr 2026 14:00:00 +0200 Subject: [PATCH 01/21] feat(efp): recognize profile links --- packages/plugins/EFP/README.md | 22 ++++++ packages/plugins/EFP/package.json | 22 ++++++ .../plugins/EFP/src/SiteAdaptor/index.tsx | 8 ++ packages/plugins/EFP/src/base.ts | 22 ++++++ packages/plugins/EFP/src/constants.ts | 17 ++++ packages/plugins/EFP/src/env.d.ts | 1 + packages/plugins/EFP/src/helpers/url.ts | 78 +++++++++++++++++++ packages/plugins/EFP/src/index.ts | 2 + packages/plugins/EFP/src/register.ts | 11 +++ packages/plugins/EFP/src/tests/url.ts | 61 +++++++++++++++ packages/plugins/EFP/tsconfig.json | 13 ++++ packages/shared-base/src/types/PluginID.ts | 1 + 12 files changed, 258 insertions(+) create mode 100644 packages/plugins/EFP/README.md create mode 100644 packages/plugins/EFP/package.json create mode 100644 packages/plugins/EFP/src/SiteAdaptor/index.tsx create mode 100644 packages/plugins/EFP/src/base.ts create mode 100644 packages/plugins/EFP/src/constants.ts create mode 100644 packages/plugins/EFP/src/env.d.ts create mode 100644 packages/plugins/EFP/src/helpers/url.ts create mode 100644 packages/plugins/EFP/src/index.ts create mode 100644 packages/plugins/EFP/src/register.ts create mode 100644 packages/plugins/EFP/src/tests/url.ts create mode 100644 packages/plugins/EFP/tsconfig.json diff --git a/packages/plugins/EFP/README.md b/packages/plugins/EFP/README.md new file mode 100644 index 000000000000..95e56b8b0e69 --- /dev/null +++ b/packages/plugins/EFP/README.md @@ -0,0 +1,22 @@ +# Ethereum Follow Protocol plugin + +## TODOs + +- Detect direct `ethfollow.xyz` and `efp.app` profile/list links in supported post content. +- Render a compact Ethereum Follow Protocol profile card in Twitter/X posts. +- Fetch profile/list details from the EFP data API and keep a URL-derived fallback card when the API fails. +- Use EFP-generated images for profile and Top 8 previews. + +## Referenced resources + +- https://efp.app +- https://docs.efp.app +- https://data.ethfollow.xyz/api/v1 +- https://github.com/ethereumfollowprotocol/app +- https://github.com/ethereumfollowprotocol/api-v2 + +## Known issues / Caveats + +- The card intentionally accepts only direct one-segment profile/list URLs with an optional `topEight=true` query. +- Reserved EFP app routes such as `/api`, `/og`, `/assets`, `/leaderboard`, `/integrations`, `/team`, and `/swipe` are ignored. +- The embed uses EFP-generated preview images instead of arbitrary ENS avatar or header records to keep CSP changes narrow. diff --git a/packages/plugins/EFP/package.json b/packages/plugins/EFP/package.json new file mode 100644 index 000000000000..41a3338b46e2 --- /dev/null +++ b/packages/plugins/EFP/package.json @@ -0,0 +1,22 @@ +{ + "name": "@masknet/plugin-efp", + "private": true, + "sideEffects": [ + "./src/register.ts" + ], + "type": "module", + "exports": { + ".": { + "mask-src": "./src/index.ts", + "default": "./dist/index.js" + }, + "./register": { + "mask-src": "./src/register.ts", + "default": "./dist/register.js" + } + }, + "dependencies": { + "@masknet/plugin-infra": "workspace:^", + "@masknet/shared-base": "workspace:^" + } +} diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx new file mode 100644 index 000000000000..dd6e1d6224e8 --- /dev/null +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -0,0 +1,8 @@ +import type { Plugin } from '@masknet/plugin-infra' +import { base } from '../base.js' + +const site: Plugin.SiteAdaptor.Definition = { + ...base, +} + +export default site diff --git a/packages/plugins/EFP/src/base.ts b/packages/plugins/EFP/src/base.ts new file mode 100644 index 000000000000..4f9a47cdab0b --- /dev/null +++ b/packages/plugins/EFP/src/base.ts @@ -0,0 +1,22 @@ +import type { Plugin } from '@masknet/plugin-infra' +import { DEFAULT_PLUGIN_PUBLISHER, EnhanceableSite } from '@masknet/shared-base' +import { EFP_PROFILE_URL_PATTERN, PLUGIN_DESCRIPTION, PLUGIN_ID, PLUGIN_NAME } from './constants.js' + +export const base: Plugin.Shared.Definition = { + ID: PLUGIN_ID, + name: { fallback: PLUGIN_NAME }, + description: { fallback: PLUGIN_DESCRIPTION }, + publisher: DEFAULT_PLUGIN_PUBLISHER, + enableRequirement: { + supports: { + type: 'opt-out', + sites: { + [EnhanceableSite.Localhost]: true, + }, + }, + target: 'stable', + }, + contribution: { + postContent: new Set([EFP_PROFILE_URL_PATTERN]), + }, +} diff --git a/packages/plugins/EFP/src/constants.ts b/packages/plugins/EFP/src/constants.ts new file mode 100644 index 000000000000..31ffd4c815a9 --- /dev/null +++ b/packages/plugins/EFP/src/constants.ts @@ -0,0 +1,17 @@ +import { PluginID } from '@masknet/shared-base' + +export const PLUGIN_ID = PluginID.EFP +export const PLUGIN_NAME = 'Ethereum Follow Protocol' +export const PLUGIN_DESCRIPTION = 'A native Ethereum protocol for following and tagging Ethereum accounts.' +export const EFP_APP_URL = 'https://efp.app' +export const EFP_API_URL = 'https://data.ethfollow.xyz/api/v1' + +const RESERVED_ROUTE_PATTERN = + 'api(?:[/?#]|$)|og(?:[/?#]|$)|assets(?:[/?#]|$)|leaderboard(?:[/?#]|$)|integrations(?:[/?#]|$)|team(?:[/?#]|$)|swipe(?:[/?#]|$)' +const ENS_LABEL_PATTERN = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?' +const EFP_USER_PATTERN = `(?:0x[\\dA-Fa-f]{40}|[1-9]\\d*|(?:${ENS_LABEL_PATTERN}\\.)+${ENS_LABEL_PATTERN})` + +export const EFP_PROFILE_URL_PATTERN = new RegExp( + `^https:\\/\\/(?:www\\.)?(?:ethfollow\\.xyz|efp\\.app)\\/(?!${RESERVED_ROUTE_PATTERN})${EFP_USER_PATTERN}(?:\\?topEight=true)?$`, + 'u', +) diff --git a/packages/plugins/EFP/src/env.d.ts b/packages/plugins/EFP/src/env.d.ts new file mode 100644 index 000000000000..868322d5ff30 --- /dev/null +++ b/packages/plugins/EFP/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/plugins/EFP/src/helpers/url.ts b/packages/plugins/EFP/src/helpers/url.ts new file mode 100644 index 000000000000..11fd60050a6c --- /dev/null +++ b/packages/plugins/EFP/src/helpers/url.ts @@ -0,0 +1,78 @@ +import { EFP_APP_URL } from '../constants.js' + +const EFP_HOSTS = new Set(['efp.app', 'www.efp.app', 'ethfollow.xyz', 'www.ethfollow.xyz']) +const RESERVED_PATHS = new Set(['api', 'og', 'assets', 'leaderboard', 'integrations', 'team', 'swipe']) +const ADDRESS_PATTERN = /^0x[\dA-Fa-f]{40}$/u +const LIST_PATTERN = /^[1-9]\d*$/u +const ENS_LABEL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u + +export interface EFPProfileLink { + user: string + type: 'list' | 'user' + topEight: boolean + profileUrl: string + imageUrl: string + apiPath: string +} + +export function parseEFPProfileLink(link: string): EFPProfileLink | null { + const url = parseURL(link) + if (!url) return null + if (url.protocol !== 'https:') return null + if (!EFP_HOSTS.has(url.hostname)) return null + if (url.hash) return null + + const segments = url.pathname.split('/').filter(Boolean) + if (segments.length !== 1) return null + + const user = safeDecodeURIComponent(segments[0]) + if (!user) return null + if (RESERVED_PATHS.has(user.toLowerCase())) return null + if (!isSupportedUser(user)) return null + + const searchParams = Array.from(url.searchParams.entries()) + const topEight = searchParams.length === 1 && searchParams[0][0] === 'topEight' && searchParams[0][1] === 'true' + if (url.search && !topEight) return null + + const type = LIST_PATTERN.test(user) ? 'list' : 'user' + const encodedUser = encodeURIComponent(user) + const query = topEight ? '?topEight=true' : '' + + return { + user, + type, + topEight, + profileUrl: `${EFP_APP_URL}/${encodedUser}${query}`, + imageUrl: + topEight ? `${EFP_APP_URL}/api/top-eight?user=${encodedUser}` : `${EFP_APP_URL}/og?user=${encodedUser}`, + apiPath: `/${type === 'list' ? 'lists' : 'users'}/${encodedUser}/details`, + } +} + +export function isEFPProfileLink(link: string): boolean { + return parseEFPProfileLink(link) !== null +} + +function parseURL(link: string) { + try { + return new URL(/^https?:\/\//u.test(link) ? link : `https://${link}`) + } catch { + return null + } +} + +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value) + } catch { + return '' + } +} + +function isSupportedUser(user: string) { + if (ADDRESS_PATTERN.test(user)) return true + if (LIST_PATTERN.test(user)) return true + const labels = user.split('.') + if (labels.length < 2) return false + return labels.every((label) => ENS_LABEL_PATTERN.test(label)) +} diff --git a/packages/plugins/EFP/src/index.ts b/packages/plugins/EFP/src/index.ts new file mode 100644 index 000000000000..5ba94dda1450 --- /dev/null +++ b/packages/plugins/EFP/src/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js' +export * from './helpers/url.js' diff --git a/packages/plugins/EFP/src/register.ts b/packages/plugins/EFP/src/register.ts new file mode 100644 index 000000000000..b163fd4dfd9f --- /dev/null +++ b/packages/plugins/EFP/src/register.ts @@ -0,0 +1,11 @@ +import { registerPlugin } from '@masknet/plugin-infra' +import { base } from './base.js' + +registerPlugin({ + ...base, + SiteAdaptor: { + load: () => import('./SiteAdaptor/index.js'), + hotModuleReload: (hot) => + import.meta.webpackHot?.accept('./SiteAdaptor', () => hot(import('./SiteAdaptor/index.js'))), + }, +}) diff --git a/packages/plugins/EFP/src/tests/url.ts b/packages/plugins/EFP/src/tests/url.ts new file mode 100644 index 000000000000..ba3b9db6cb71 --- /dev/null +++ b/packages/plugins/EFP/src/tests/url.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { EFP_PROFILE_URL_PATTERN } from '../constants.js' +import { parseEFPProfileLink } from '../helpers/url.js' + +describe('EFP profile links', () => { + it.each([ + ['ethfollow.xyz/vitalik.eth', { user: 'vitalik.eth', type: 'user', topEight: false }], + [ + 'https://efp.app/0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + { user: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', type: 'user', topEight: false }, + ], + ['https://ethfollow.xyz/6509', { user: '6509', type: 'list', topEight: false }], + ['https://efp.app/vitalik.eth?topEight=true', { user: 'vitalik.eth', type: 'user', topEight: true }], + ] as const)('parses %s', (link, expected) => { + const result = parseEFPProfileLink(link) + + expect(result).toMatchObject(expected) + expect(result?.profileUrl).toContain(`/${expected.user}`) + }) + + it.each([ + 'https://ethfollow.xyz/api', + 'https://efp.app/og?user=vitalik.eth', + 'https://efp.app/assets/logo.svg', + 'https://efp.app/leaderboard', + 'https://efp.app/integrations', + 'https://efp.app/team', + 'https://efp.app/swipe', + 'https://ethfollow.xyz/vitalik.eth/followers', + 'https://example.com/vitalik.eth', + 'https://efp.app/not a valid name', + 'https://efp.app/not..valid.eth', + 'https://efp.app/vitalik.eth?topEight=false', + 'https://efp.app/vitalik.eth?foo=bar', + 'https://efp.app/vitalik.eth#profile', + 'http://efp.app/vitalik.eth', + ])('rejects %s', (link) => { + expect(parseEFPProfileLink(link)).toBeNull() + }) + + it.each([ + 'https://ethfollow.xyz/vitalik.eth', + 'https://efp.app/0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + 'https://ethfollow.xyz/6509', + 'https://efp.app/vitalik.eth?topEight=true', + ])('contribution pattern matches %s', (link) => { + expect(EFP_PROFILE_URL_PATTERN.test(link)).toBe(true) + }) + + it.each([ + 'https://efp.app/api', + 'https://efp.app/og?user=vitalik.eth', + 'https://efp.app/vitalik.eth/followers', + 'https://efp.app/not a valid name', + 'https://efp.app/not..valid.eth', + 'https://efp.app/vitalik.eth?topEight=false', + 'https://efp.app/vitalik.eth#profile', + ])('contribution pattern rejects %s', (link) => { + expect(EFP_PROFILE_URL_PATTERN.test(link)).toBe(false) + }) +}) diff --git a/packages/plugins/EFP/tsconfig.json b/packages/plugins/EFP/tsconfig.json new file mode 100644 index 000000000000..39c7bdc477cb --- /dev/null +++ b/packages/plugins/EFP/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": ["src", "src/**/*.json"], + "references": [ + { "path": "../../plugin-infra/tsconfig.json" }, + { "path": "../../shared-base/tsconfig.json" } + ] +} diff --git a/packages/shared-base/src/types/PluginID.ts b/packages/shared-base/src/types/PluginID.ts index 208592b5b478..27688951522c 100644 --- a/packages/shared-base/src/types/PluginID.ts +++ b/packages/shared-base/src/types/PluginID.ts @@ -31,6 +31,7 @@ export enum PluginID { Wallet = 'com.maskbook.wallet', FileService = 'com.maskbook.fileservice', CyberConnect = 'me.cyberconnect.app', + EFP = 'xyz.ethfollow', GoPlusSecurity = 'io.gopluslabs.security', CrossChainBridge = 'io.mask.cross-chain-bridge', Web3Profile = 'io.mask.web3-profile', From 05d30d529e1b77225583a2bcd7054be6e227a2c2 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 26 Apr 2026 14:00:00 +0200 Subject: [PATCH 02/21] feat(efp): render twitter embeds --- cspell.json | 1 + packages/mask/package.json | 1 + packages/mask/shared/plugin-infra/register.js | 1 + packages/plugins/EFP/package.json | 5 +- .../EFP/src/SiteAdaptor/ProfileCard.tsx | 241 ++++++++++++++++++ .../plugins/EFP/src/SiteAdaptor/index.tsx | 50 +++- packages/plugins/EFP/tsconfig.json | 4 +- packages/plugins/tsconfig.json | 1 + pnpm-lock.yaml | 21 ++ security/content-security-policy.json | 2 + 10 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx diff --git a/cspell.json b/cspell.json index 0d984fa15b20..6149cd7013a5 100644 --- a/cspell.json +++ b/cspell.json @@ -77,6 +77,7 @@ "dotbit", "dsearch", "enhanceable", + "ethfollow", "evmos", "farcaster", "favourites", diff --git a/packages/mask/package.json b/packages/mask/package.json index dcfe23ed59c3..b305a6cf1854 100644 --- a/packages/mask/package.json +++ b/packages/mask/package.json @@ -48,6 +48,7 @@ "@masknet/plugin-collectible": "workspace:^", "@masknet/plugin-cross-chain-bridge": "workspace:^", "@masknet/plugin-debugger": "workspace:^", + "@masknet/plugin-efp": "workspace:^", "@masknet/plugin-file-service": "workspace:^", "@masknet/plugin-gitcoin": "workspace:^", "@masknet/plugin-go-plus-security": "workspace:^", diff --git a/packages/mask/shared/plugin-infra/register.js b/packages/mask/shared/plugin-infra/register.js index 4124911dfd1c..c1169381ab5a 100644 --- a/packages/mask/shared/plugin-infra/register.js +++ b/packages/mask/shared/plugin-infra/register.js @@ -7,6 +7,7 @@ import '@masknet/plugin-go-plus-security/register' import '@masknet/plugin-cross-chain-bridge/register' import '@masknet/plugin-web3-profile/register' import '@masknet/plugin-handle/register' +import '@masknet/plugin-efp/register' import '@masknet/plugin-approval/register' import '@masknet/plugin-gitcoin/register' import '@masknet/plugin-scam-warning/register' diff --git a/packages/plugins/EFP/package.json b/packages/plugins/EFP/package.json index 41a3338b46e2..ec4d05eaeaf0 100644 --- a/packages/plugins/EFP/package.json +++ b/packages/plugins/EFP/package.json @@ -16,7 +16,10 @@ } }, "dependencies": { + "@masknet/icons": "workspace:^", "@masknet/plugin-infra": "workspace:^", - "@masknet/shared-base": "workspace:^" + "@masknet/shared-base": "workspace:^", + "@masknet/theme": "workspace:^", + "@masknet/typed-message": "workspace:^" } } diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx new file mode 100644 index 000000000000..fa397846fddd --- /dev/null +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -0,0 +1,241 @@ +import { Icons } from '@masknet/icons' +import { makeStyles } from '@masknet/theme' +import { Box, Link, Stack, Typography } from '@mui/material' +import { useEffect, useMemo, useReducer } from 'react' +import { EFP_API_URL, PLUGIN_NAME } from '../constants.js' +import type { EFPProfileLink } from '../helpers/url.js' + +interface EFPProfileResponse { + address?: string + ens?: { + name?: string | null + records?: Record | null + } | null + followers_count?: number | string + following_count?: number | string + primary_list?: string | null +} + +interface ProfileCardProps { + profileLink: EFPProfileLink +} + +interface EFPProfileState { + data: EFPProfileResponse | null + loading: boolean +} + +type EFPProfileAction = + | { type: 'loading' } + | { type: 'success'; data: EFPProfileResponse | null } + | { type: 'error' } + +const formatter = new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +const useStyles = makeStyles()((theme) => ({ + root: { + padding: theme.spacing(1.5), + paddingTop: 0, + }, + card: { + overflow: 'hidden', + borderRadius: 8, + border: `1px solid ${theme.palette.maskColor.line}`, + color: theme.palette.maskColor.main, + background: theme.palette.maskColor.bottom, + }, + image: { + display: 'block', + width: '100%', + aspectRatio: '1.91 / 1', + objectFit: 'cover', + background: theme.palette.maskColor.bg, + }, + body: { + padding: theme.spacing(1.5), + gap: theme.spacing(1), + }, + eyebrow: { + color: theme.palette.maskColor.second, + fontWeight: 700, + lineHeight: 1, + }, + title: { + fontWeight: 700, + wordBreak: 'break-word', + lineHeight: 1.25, + }, + description: { + color: theme.palette.maskColor.second, + display: '-webkit-box', + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + }, + metrics: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + }, + metric: { + minWidth: 86, + borderRadius: 8, + padding: theme.spacing(0.75, 1), + background: theme.palette.maskColor.bg, + }, + metricValue: { + fontWeight: 700, + lineHeight: 1.2, + }, + metricLabel: { + color: theme.palette.maskColor.second, + lineHeight: 1.2, + }, + footer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(1), + }, + link: { + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(0.5), + fontWeight: 700, + textDecoration: 'none', + }, +})) + +export function ProfileCard({ profileLink }: ProfileCardProps) { + const { classes } = useStyles() + const { data, loading } = useEFPProfile(profileLink) + const displayName = useMemo(() => getDisplayName(profileLink, data), [profileLink, data]) + const description = data?.ens?.records?.description + const primaryList = data?.primary_list + + return ( + + + + + + {profileLink.topEight ? 'EFP Top 8' : PLUGIN_NAME} + + + {displayName} + + {description ? + + {description} + + : null} + + + + {primaryList ? + + : profileLink.type === 'list' ? + + : null} + + + + {profileLink.type === 'list' ? 'EFP list' : 'EFP profile'} + + + View on EFP + + + + + + + ) +} + +function Metric({ label, value }: { label: string; value: string }) { + const { classes } = useStyles() + return ( + + + {value} + + + {label} + + + ) +} + +function useEFPProfile(profileLink: EFPProfileLink) { + const [state, dispatch] = useReducer(reduceEFPProfileState, { + data: null, + loading: true, + }) + + useEffect(() => { + let cancelled = false + dispatch({ type: 'loading' }) + + fetchEFPProfile(profileLink.apiPath) + .then((data) => { + if (cancelled) return + dispatch({ type: 'success', data: isProfileResponse(data) ? data : null }) + }) + .catch(() => { + if (cancelled) return + dispatch({ type: 'error' }) + }) + + return () => { + cancelled = true + } + }, [profileLink.apiPath]) + + return state +} + +function reduceEFPProfileState(_: EFPProfileState, action: EFPProfileAction): EFPProfileState { + if (action.type === 'loading') return { data: null, loading: true } + if (action.type === 'success') return { data: action.data, loading: false } + return { data: null, loading: false } +} + +async function fetchEFPProfile(apiPath: string) { + const response = await fetch(`${EFP_API_URL}${apiPath}`, { + headers: { + Accept: 'application/json', + }, + }) + if (!response.ok) throw new Error('Failed to fetch EFP profile') + return response.json() as Promise +} + +function isProfileResponse(value: EFPProfileResponse | null): value is EFPProfileResponse { + return !!value && (typeof value.address === 'string' || typeof value.primary_list === 'string') +} + +function getDisplayName(profileLink: EFPProfileLink, data: EFPProfileResponse | null) { + const ensName = data?.ens?.name + if (ensName) return ensName + if (profileLink.type === 'list') return `List #${profileLink.user}` + return truncateAddress(profileLink.user) +} + +function truncateAddress(value: string) { + if (!/^0x[\dA-Fa-f]{40}$/u.test(value)) return value + return `${value.slice(0, 6)}...${value.slice(-4)}` +} + +function formatCount(value: string | number | undefined) { + const count = Number(value) + if (!Number.isFinite(count)) return '--' + return formatter.format(count) +} diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index dd6e1d6224e8..f57a6883bef2 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -1,8 +1,56 @@ -import type { Plugin } from '@masknet/plugin-infra' +import { Icons } from '@masknet/icons' +import { type Plugin, usePluginWrapper, usePostInfoDetails } from '@masknet/plugin-infra/content-script' +import { parseURLs } from '@masknet/shared-base' +import { extractTextFromTypedMessage } from '@masknet/typed-message' +import { useMemo, type JSX } from 'react' import { base } from '../base.js' +import { PLUGIN_DESCRIPTION, PLUGIN_NAME } from '../constants.js' +import { parseEFPProfileLink } from '../helpers/url.js' +import { ProfileCard } from './ProfileCard.js' + +function Renderer({ url }: { url: string }) { + const profileLink = useMemo(() => parseEFPProfileLink(url), [url]) + usePluginWrapper(!!profileLink, { name: PLUGIN_NAME }) + + if (!profileLink) return null + return +} const site: Plugin.SiteAdaptor.Definition = { ...base, + DecryptedInspector(props): JSX.Element | null { + const link = useMemo(() => { + const text = extractTextFromTypedMessage(props.message) + if (text.isNone()) return null + return parseURLs(text.value, false).find((x) => parseEFPProfileLink(x)) + }, [props.message]) + + if (!link) return null + return + }, + PostInspector(): JSX.Element | null { + const links = usePostInfoDetails.mentionedLinks() + const link = links.find((x) => parseEFPProfileLink(x)) + + if (!link) return null + return + }, + ApplicationEntries: [ + { + ApplicationEntryID: base.ID, + category: 'dapp', + marketListSortingPriority: 18, + description: PLUGIN_DESCRIPTION, + name: PLUGIN_NAME, + icon: , + tutorialLink: 'https://docs.efp.app/intro', + }, + ], + wrapperProps: { + icon: , + backgroundGradient: + 'linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%), linear-gradient(90deg, rgba(255, 224, 103, 0.2) 0%, rgba(211, 234, 244, 0.2) 100%), #FFFFFF', + }, } export default site diff --git a/packages/plugins/EFP/tsconfig.json b/packages/plugins/EFP/tsconfig.json index 39c7bdc477cb..909554b29dc5 100644 --- a/packages/plugins/EFP/tsconfig.json +++ b/packages/plugins/EFP/tsconfig.json @@ -8,6 +8,8 @@ "include": ["src", "src/**/*.json"], "references": [ { "path": "../../plugin-infra/tsconfig.json" }, - { "path": "../../shared-base/tsconfig.json" } + { "path": "../../shared-base/tsconfig.json" }, + { "path": "../../theme/tsconfig.json" }, + { "path": "../../typed-message/base/tsconfig.json" } ] } diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json index 66e0be2b565f..20c78b1a2d95 100644 --- a/packages/plugins/tsconfig.json +++ b/packages/plugins/tsconfig.json @@ -10,6 +10,7 @@ { "path": "./CrossChainBridge/tsconfig.json" }, { "path": "./CyberConnect/tsconfig.json" }, { "path": "./Debugger/tsconfig.json" }, + { "path": "./EFP/tsconfig.json" }, { "path": "./FileService/tsconfig.json" }, { "path": "./Gitcoin/tsconfig.json" }, { "path": "./GoPlusSecurity/tsconfig.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044000a939f1..98c99cda882f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,6 +422,9 @@ importers: '@masknet/plugin-debugger': specifier: workspace:^ version: link:../plugins/Debugger + '@masknet/plugin-efp': + specifier: workspace:^ + version: link:../plugins/EFP '@masknet/plugin-file-service': specifier: workspace:^ version: link:../plugins/FileService @@ -1231,6 +1234,24 @@ importers: specifier: workspace:^ version: link:../../web3-telemetry + packages/plugins/EFP: + dependencies: + '@masknet/icons': + specifier: workspace:^ + version: link:../../icons + '@masknet/plugin-infra': + specifier: workspace:^ + version: link:../../plugin-infra + '@masknet/shared-base': + specifier: workspace:^ + version: link:../../shared-base + '@masknet/theme': + specifier: workspace:^ + version: link:../../theme + '@masknet/typed-message': + specifier: workspace:^ + version: link:../../typed-message/base + packages/plugins/FileService: dependencies: '@dimensiondev/common-protocols': diff --git a/security/content-security-policy.json b/security/content-security-policy.json index b73645506589..307820632bef 100644 --- a/security/content-security-policy.json +++ b/security/content-security-policy.json @@ -83,6 +83,7 @@ "https://prod-api.kosetto.com", "https://grants-stack-indexer-v2.gitcoin.co/", + "https://data.ethfollow.xyz", "https://t.co", @@ -104,6 +105,7 @@ "https://imagedelivery.net", "https://bridge.metis.io", "https://static.okx.com", + "https://efp.app", "https://dzyb4dm7r8k8w.cloudfront.net", "https://purecatamphetamine.github.io/country-flag-icons/" ], From abf12fe93ac30dec4ffef2c1d3f8b80bf0ce6d66 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 27 Apr 2026 21:33:19 +0200 Subject: [PATCH 03/21] fix(efp): hide native twitter preview --- .../EFP/src/SiteAdaptor/ProfileCard.tsx | 44 +++++- .../plugins/EFP/src/SiteAdaptor/index.tsx | 143 +++++++++++++++++- packages/plugins/EFP/src/constants.ts | 1 + 3 files changed, 184 insertions(+), 4 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx index fa397846fddd..47da58ece998 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -1,8 +1,8 @@ import { Icons } from '@masknet/icons' import { makeStyles } from '@masknet/theme' import { Box, Link, Stack, Typography } from '@mui/material' -import { useEffect, useMemo, useReducer } from 'react' -import { EFP_API_URL, PLUGIN_NAME } from '../constants.js' +import { useEffect, useMemo, useReducer, useState } from 'react' +import { EFP_API_URL, EFP_FALLBACK_IMAGE_URL, PLUGIN_NAME } from '../constants.js' import type { EFPProfileLink } from '../helpers/url.js' interface EFPProfileResponse { @@ -54,6 +54,15 @@ const useStyles = makeStyles()((theme) => ({ objectFit: 'cover', background: theme.palette.maskColor.bg, }, + imageFallback: { + display: 'flex', + width: '100%', + aspectRatio: '1.91 / 1', + alignItems: 'center', + justifyContent: 'center', + background: 'linear-gradient(135deg, #f1f3fe 0%, #dff2fb 45%, #ecfffd 100%)', + color: '#333333', + }, body: { padding: theme.spacing(1.5), gap: theme.spacing(1), @@ -119,7 +128,7 @@ export function ProfileCard({ profileLink }: ProfileCardProps) { return ( - + {profileLink.topEight ? 'EFP Top 8' : PLUGIN_NAME} @@ -160,6 +169,35 @@ export function ProfileCard({ profileLink }: ProfileCardProps) { ) } +function ProfileImage({ profileLink }: ProfileCardProps) { + const { classes } = useStyles() + const [imageUrl, setImageUrl] = useState(profileLink.imageUrl) + const [failed, setFailed] = useState(false) + + if (failed) { + return ( + + + + ) + } + + return ( + { + if (imageUrl === EFP_FALLBACK_IMAGE_URL) { + setFailed(true) + return + } + setImageUrl(EFP_FALLBACK_IMAGE_URL) + }} + /> + ) +} + function Metric({ label, value }: { label: string; value: string }) { const { classes } = useStyles() return ( diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index f57a6883bef2..98754553529e 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -2,7 +2,7 @@ import { Icons } from '@masknet/icons' import { type Plugin, usePluginWrapper, usePostInfoDetails } from '@masknet/plugin-infra/content-script' import { parseURLs } from '@masknet/shared-base' import { extractTextFromTypedMessage } from '@masknet/typed-message' -import { useMemo, type JSX } from 'react' +import { useEffect, useMemo, type JSX } from 'react' import { base } from '../base.js' import { PLUGIN_DESCRIPTION, PLUGIN_NAME } from '../constants.js' import { parseEFPProfileLink } from '../helpers/url.js' @@ -35,6 +35,9 @@ const site: Plugin.SiteAdaptor.Definition = { if (!link) return null return }, + GlobalInjection(): JSX.Element | null { + return + }, ApplicationEntries: [ { ApplicationEntryID: base.ID, @@ -54,3 +57,141 @@ const site: Plugin.SiteAdaptor.Definition = { } export default site + +function NativeTwitterCardCleaner(): null { + useEffect(() => { + if (!isTwitterHost()) return + const root = document.body + if (!root) return + + const hiddenCards = new Map() + let scheduled = false + + const hideCards = () => { + scheduled = false + for (const card of findNativeEFPCards(root)) { + if (!hiddenCards.has(card)) { + hiddenCards.set(card, { + ariaHidden: card.getAttribute('aria-hidden'), + display: card.style.display, + visibility: card.style.visibility, + }) + } + card.style.display = 'none' + card.style.visibility = 'hidden' + card.setAttribute('aria-hidden', 'true') + } + } + const scheduleHideCards = () => { + if (scheduled) return + scheduled = true + requestAnimationFrame(hideCards) + } + + hideCards() + const observer = new MutationObserver(scheduleHideCards) + observer.observe(root, { + childList: true, + subtree: true, + characterData: true, + }) + + return () => { + observer.disconnect() + for (const [card, previous] of hiddenCards) { + card.style.display = previous.display + card.style.visibility = previous.visibility + if (previous.ariaHidden === null) card.removeAttribute('aria-hidden') + else card.setAttribute('aria-hidden', previous.ariaHidden) + } + } + }, []) + + return null +} + +function isTwitterHost() { + return location.hostname === 'x.com' || location.hostname.endsWith('.x.com') || location.hostname === 'twitter.com' +} + +function findNativeEFPCards(root: ParentNode) { + const cards = new Set() + + for (const card of root.querySelectorAll('article [data-testid="card.wrapper"]')) { + if (hasEFPHostMetadata(card)) cards.add(card) + } + + for (const link of root.querySelectorAll( + 'article a[href*="efp.app"], article a[href*="ethfollow.xyz"]', + )) { + const card = getNativeCardContainer(link) + if (card) cards.add(card) + } + + for (const marker of root.querySelectorAll('article span, article a, article div[dir]')) { + if (marker.closest('[data-testid="tweetText"]')) continue + if (!hasNativeEFPText(marker)) continue + const card = getNativeCardContainer(marker) + if (card) cards.add(card) + } + + return cards +} + +function getNativeCardContainer(node: HTMLElement) { + if (node.closest('[data-testid="tweetText"]')) return null + + const article = node.closest('article') + if (!article) return null + + const cardWrapper = node.closest('[data-testid="card.wrapper"]') + if (cardWrapper && article.contains(cardWrapper)) return cardWrapper + + const roleLink = node.closest('[role="link"]') + if (roleLink && article.contains(roleLink) && !roleLink.closest('[data-testid="tweetText"]')) return roleLink + + const anchor = node.closest('a[href]') + if (anchor && article.contains(anchor) && !anchor.closest('[data-testid="tweetText"]')) return anchor + + let current: HTMLElement | null = node + let card: HTMLElement | null = null + while (current && current !== article) { + if (current.querySelector('img') && hasNativeEFPText(current)) card = current + current = current.parentElement + } + + return card +} + +function hasEFPHostMetadata(element: HTMLElement) { + if (hasNativeEFPText(element)) return true + + for (const item of element.querySelectorAll('a[href], img, [title], [aria-label]')) { + const haystacks = [ + item.getAttribute('href'), + item.getAttribute('title'), + item.getAttribute('aria-label'), + item.getAttribute('alt'), + ] + if (haystacks.some(isEFPHostMetadata)) return true + } + + return isEFPHostMetadata(element.getAttribute('title')) || isEFPHostMetadata(element.getAttribute('aria-label')) +} + +function hasNativeEFPText(element: HTMLElement) { + const text = element.textContent?.toLowerCase() ?? '' + return text.includes('efp.app') || text.includes('ethfollow.xyz') +} + +function isEFPHostMetadata(value: string | null) { + if (!value) return false + const lowerValue = value.toLowerCase() + if (lowerValue.includes('efp.app') || lowerValue.includes('ethfollow.xyz')) return true + try { + const url = new URL(value, location.href) + return url.hostname === 'efp.app' || url.hostname === 'ethfollow.xyz' + } catch { + return false + } +} diff --git a/packages/plugins/EFP/src/constants.ts b/packages/plugins/EFP/src/constants.ts index 31ffd4c815a9..c3b4747c7c80 100644 --- a/packages/plugins/EFP/src/constants.ts +++ b/packages/plugins/EFP/src/constants.ts @@ -5,6 +5,7 @@ export const PLUGIN_NAME = 'Ethereum Follow Protocol' export const PLUGIN_DESCRIPTION = 'A native Ethereum protocol for following and tagging Ethereum accounts.' export const EFP_APP_URL = 'https://efp.app' export const EFP_API_URL = 'https://data.ethfollow.xyz/api/v1' +export const EFP_FALLBACK_IMAGE_URL = `${EFP_APP_URL}/assets/art/default-header.svg` const RESERVED_ROUTE_PATTERN = 'api(?:[/?#]|$)|og(?:[/?#]|$)|assets(?:[/?#]|$)|leaderboard(?:[/?#]|$)|integrations(?:[/?#]|$)|team(?:[/?#]|$)|swipe(?:[/?#]|$)' From 5d9b7bb70f8fd627f3e4f136b80ceb31c4555903 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 27 Apr 2026 21:58:17 +0200 Subject: [PATCH 04/21] fix(efp): scope native twitter card hiding to post root Replace the body-wide GlobalInjection MutationObserver with a per-post hook that uses usePostInfoDetails.rootNode() (NextID pattern) and queries [data-testid=card.wrapper] within the post (Mask Twitter PostInspector pattern). The previous broad scan plus EFP-specific metadata heuristics didn't reliably catch Twitter's lazy-rendered card, leaving a duplicate native preview below the EFP card. --- .../plugins/EFP/src/SiteAdaptor/index.tsx | 168 +++--------------- 1 file changed, 27 insertions(+), 141 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 98754553529e..bf51cf3dbb04 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -10,12 +10,39 @@ import { ProfileCard } from './ProfileCard.js' function Renderer({ url }: { url: string }) { const profileLink = useMemo(() => parseEFPProfileLink(url), [url]) + const rootNode = usePostInfoDetails.rootNode() usePluginWrapper(!!profileLink, { name: PLUGIN_NAME }) + useHideNativeTwitterCard(rootNode, !!profileLink) if (!profileLink) return null return } +function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean) { + useEffect(() => { + if (!rootNode || !enabled) return + + const hide = () => { + const card = rootNode.querySelector('[data-testid="card.wrapper"]') + if (!card || card.style.display === 'none') return + if (!isEFPCard(card)) return + card.style.display = 'none' + card.setAttribute('aria-hidden', 'true') + } + + hide() + const observer = new MutationObserver(hide) + observer.observe(rootNode, { childList: true, subtree: true }) + return () => observer.disconnect() + }, [rootNode, enabled]) +} + +function isEFPCard(card: HTMLElement) { + if (card.querySelector('a[href*="efp.app"], a[href*="ethfollow.xyz"]')) return true + const text = card.textContent?.toLowerCase() ?? '' + return text.includes('efp.app') || text.includes('ethfollow.xyz') +} + const site: Plugin.SiteAdaptor.Definition = { ...base, DecryptedInspector(props): JSX.Element | null { @@ -35,9 +62,6 @@ const site: Plugin.SiteAdaptor.Definition = { if (!link) return null return }, - GlobalInjection(): JSX.Element | null { - return - }, ApplicationEntries: [ { ApplicationEntryID: base.ID, @@ -57,141 +81,3 @@ const site: Plugin.SiteAdaptor.Definition = { } export default site - -function NativeTwitterCardCleaner(): null { - useEffect(() => { - if (!isTwitterHost()) return - const root = document.body - if (!root) return - - const hiddenCards = new Map() - let scheduled = false - - const hideCards = () => { - scheduled = false - for (const card of findNativeEFPCards(root)) { - if (!hiddenCards.has(card)) { - hiddenCards.set(card, { - ariaHidden: card.getAttribute('aria-hidden'), - display: card.style.display, - visibility: card.style.visibility, - }) - } - card.style.display = 'none' - card.style.visibility = 'hidden' - card.setAttribute('aria-hidden', 'true') - } - } - const scheduleHideCards = () => { - if (scheduled) return - scheduled = true - requestAnimationFrame(hideCards) - } - - hideCards() - const observer = new MutationObserver(scheduleHideCards) - observer.observe(root, { - childList: true, - subtree: true, - characterData: true, - }) - - return () => { - observer.disconnect() - for (const [card, previous] of hiddenCards) { - card.style.display = previous.display - card.style.visibility = previous.visibility - if (previous.ariaHidden === null) card.removeAttribute('aria-hidden') - else card.setAttribute('aria-hidden', previous.ariaHidden) - } - } - }, []) - - return null -} - -function isTwitterHost() { - return location.hostname === 'x.com' || location.hostname.endsWith('.x.com') || location.hostname === 'twitter.com' -} - -function findNativeEFPCards(root: ParentNode) { - const cards = new Set() - - for (const card of root.querySelectorAll('article [data-testid="card.wrapper"]')) { - if (hasEFPHostMetadata(card)) cards.add(card) - } - - for (const link of root.querySelectorAll( - 'article a[href*="efp.app"], article a[href*="ethfollow.xyz"]', - )) { - const card = getNativeCardContainer(link) - if (card) cards.add(card) - } - - for (const marker of root.querySelectorAll('article span, article a, article div[dir]')) { - if (marker.closest('[data-testid="tweetText"]')) continue - if (!hasNativeEFPText(marker)) continue - const card = getNativeCardContainer(marker) - if (card) cards.add(card) - } - - return cards -} - -function getNativeCardContainer(node: HTMLElement) { - if (node.closest('[data-testid="tweetText"]')) return null - - const article = node.closest('article') - if (!article) return null - - const cardWrapper = node.closest('[data-testid="card.wrapper"]') - if (cardWrapper && article.contains(cardWrapper)) return cardWrapper - - const roleLink = node.closest('[role="link"]') - if (roleLink && article.contains(roleLink) && !roleLink.closest('[data-testid="tweetText"]')) return roleLink - - const anchor = node.closest('a[href]') - if (anchor && article.contains(anchor) && !anchor.closest('[data-testid="tweetText"]')) return anchor - - let current: HTMLElement | null = node - let card: HTMLElement | null = null - while (current && current !== article) { - if (current.querySelector('img') && hasNativeEFPText(current)) card = current - current = current.parentElement - } - - return card -} - -function hasEFPHostMetadata(element: HTMLElement) { - if (hasNativeEFPText(element)) return true - - for (const item of element.querySelectorAll('a[href], img, [title], [aria-label]')) { - const haystacks = [ - item.getAttribute('href'), - item.getAttribute('title'), - item.getAttribute('aria-label'), - item.getAttribute('alt'), - ] - if (haystacks.some(isEFPHostMetadata)) return true - } - - return isEFPHostMetadata(element.getAttribute('title')) || isEFPHostMetadata(element.getAttribute('aria-label')) -} - -function hasNativeEFPText(element: HTMLElement) { - const text = element.textContent?.toLowerCase() ?? '' - return text.includes('efp.app') || text.includes('ethfollow.xyz') -} - -function isEFPHostMetadata(value: string | null) { - if (!value) return false - const lowerValue = value.toLowerCase() - if (lowerValue.includes('efp.app') || lowerValue.includes('ethfollow.xyz')) return true - try { - const url = new URL(value, location.href) - return url.hostname === 'efp.app' || url.hostname === 'ethfollow.xyz' - } catch { - return false - } -} From 1aab1571ce421bb33ceea84131642c638ce4136d Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 27 Apr 2026 22:01:40 +0200 Subject: [PATCH 05/21] fix(efp): query card.wrapper from the tweet, not from rootNode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post's rootNode (per twitter selector at packages/mask/content-script/site-adaptors/twitter.com/utils/selector.ts:186) is the tweetText/tweetPhoto/div[lang] — the card.wrapper is its sibling inside [data-testid=tweet], not a descendant. Climb up to the tweet element before querying so the native EFP card is actually found. --- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index bf51cf3dbb04..386f81f2f0a4 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -22,17 +22,21 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean useEffect(() => { if (!rootNode || !enabled) return + const tweet = rootNode.closest('[data-testid="tweet"]') ?? rootNode.closest('article') + if (!tweet) return + const hide = () => { - const card = rootNode.querySelector('[data-testid="card.wrapper"]') - if (!card || card.style.display === 'none') return - if (!isEFPCard(card)) return - card.style.display = 'none' - card.setAttribute('aria-hidden', 'true') + for (const card of tweet.querySelectorAll('[data-testid="card.wrapper"]')) { + if (card === rootNode || card.style.display === 'none') continue + if (!isEFPCard(card)) continue + card.style.display = 'none' + card.setAttribute('aria-hidden', 'true') + } } hide() const observer = new MutationObserver(hide) - observer.observe(rootNode, { childList: true, subtree: true }) + observer.observe(tweet, { childList: true, subtree: true }) return () => observer.disconnect() }, [rootNode, enabled]) } From e22513afe8cd66f782b709d3a1cef5bbd93f7973 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 27 Apr 2026 22:08:07 +0200 Subject: [PATCH 06/21] fix(efp): detect via aria-label and hide card container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native EFP detection was failing because Twitter wraps the link in t.co (no href match) and the card.wrapper's textContent only holds 'brantly.eth' — the 'efp.app' reference lives in aria-label on the inner anchor and in the 'From efp.app' footer that is a sibling of card.wrapper. Detect via aria-label so isEFPCard returns true, and hide the parent that's aria-labelledby the card so the footer is hidden along with the wrapper. --- .../plugins/EFP/src/SiteAdaptor/index.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 386f81f2f0a4..1eb013f4f280 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -27,10 +27,12 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean const hide = () => { for (const card of tweet.querySelectorAll('[data-testid="card.wrapper"]')) { - if (card === rootNode || card.style.display === 'none') continue + if (card === rootNode) continue if (!isEFPCard(card)) continue - card.style.display = 'none' - card.setAttribute('aria-hidden', 'true') + const container = getCardContainer(card) + if (container.style.display === 'none') continue + container.style.display = 'none' + container.setAttribute('aria-hidden', 'true') } } @@ -41,8 +43,23 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean }, [rootNode, enabled]) } +// Twitter renders the visible card as a parent that's `aria-labelledby` the card.wrapper id and +// also holds the "From " footer as a sibling of the wrapper. Hide the parent so the footer +// goes away with the card; fall back to the wrapper when the structure doesn't match. +function getCardContainer(card: HTMLElement): HTMLElement { + const parent = card.parentElement + if (!parent || !card.id) return card + const labelledBy = parent.getAttribute('aria-labelledby') + if (labelledBy && labelledBy.split(/\s+/).includes(card.id)) return parent + return card +} + function isEFPCard(card: HTMLElement) { if (card.querySelector('a[href*="efp.app"], a[href*="ethfollow.xyz"]')) return true + for (const el of card.querySelectorAll('[aria-label]')) { + const label = el.getAttribute('aria-label')?.toLowerCase() ?? '' + if (label.includes('efp.app') || label.includes('ethfollow.xyz')) return true + } const text = card.textContent?.toLowerCase() ?? '' return text.includes('efp.app') || text.includes('ethfollow.xyz') } From e973299e2a6ac187c6b558166b08ae81c2b6eb2e Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 28 Apr 2026 08:32:15 +0200 Subject: [PATCH 07/21] fix(efp): widen search root to article parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [data-testid="tweet"] is sometimes on a nested div (not the article) in this version of Twitter, so closest() can land on an element that doesn't contain card.wrapper. Search from article.parentElement (the timeline section / detail view container) instead — that covers both the timeline layout (card inside article) and the detail layout (card in a sibling subtree). Falls back to document.body when no article ancestor is found. --- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 1eb013f4f280..456f51cbc201 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -22,11 +22,14 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean useEffect(() => { if (!rootNode || !enabled) return - const tweet = rootNode.closest('[data-testid="tweet"]') ?? rootNode.closest('article') - if (!tweet) return + // Search from the article (or its parent in detail view, where the card sits in a sibling + // subtree) so we cover both timeline and detail layouts. + const article = rootNode.closest('article') + const searchRoot = article?.parentElement ?? rootNode.ownerDocument.body + if (!searchRoot) return const hide = () => { - for (const card of tweet.querySelectorAll('[data-testid="card.wrapper"]')) { + for (const card of searchRoot.querySelectorAll('[data-testid="card.wrapper"]')) { if (card === rootNode) continue if (!isEFPCard(card)) continue const container = getCardContainer(card) @@ -38,7 +41,7 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean hide() const observer = new MutationObserver(hide) - observer.observe(tweet, { childList: true, subtree: true }) + observer.observe(searchRoot, { childList: true, subtree: true }) return () => observer.disconnect() }, [rootNode, enabled]) } From 925c1069f3ace42505d70ab35f31ba7878dc5f39 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 28 Apr 2026 08:46:18 +0200 Subject: [PATCH 08/21] fix(efp): use card.contains(rootNode) to skip own injection target Twitter's postsContentSelector matches [data-testid="card.wrapper"] directly for link-only tweets, so rootNode can BE the card.wrapper. The strict equality check was correct for that case but missed the defensive case where rootNode might end up nested inside the wrapper. contains() covers both. --- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 456f51cbc201..f1e32d990edb 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -30,7 +30,9 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean const hide = () => { for (const card of searchRoot.querySelectorAll('[data-testid="card.wrapper"]')) { - if (card === rootNode) continue + // For link-only tweets, rootNode IS the card.wrapper — don't hide our own + // injection target. + if (card.contains(rootNode)) continue if (!isEFPCard(card)) continue const container = getCardContainer(card) if (container.style.display === 'none') continue From e92dd6378a4d26936e0d21882e777024fdc10275 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 28 Apr 2026 08:46:47 +0200 Subject: [PATCH 09/21] fix(efp): scope to article in timeline view article.parentElement is the entire timeline container, so the observer's textContent fallback in isEFPCard could hide a sibling tweet whose Twitter preview happens to mention efp.app/ethfollow.xyz (news article, embed of an EFP-related quote, etc.) even though no EFP plugin is rendering for that post. Use isFocusing to detect detail view, where the card can live in a sibling subtree of the article (per twitter's postsContentSelector at packages/mask/content-script/site-adaptors/twitter.com/utils/selector.ts:195), and only widen the search root there. Timeline view stays scoped to the article. --- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index f1e32d990edb..4ce152659b31 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -11,21 +11,24 @@ import { ProfileCard } from './ProfileCard.js' function Renderer({ url }: { url: string }) { const profileLink = useMemo(() => parseEFPProfileLink(url), [url]) const rootNode = usePostInfoDetails.rootNode() + const isFocusing = usePostInfoDetails.isFocusing?.() usePluginWrapper(!!profileLink, { name: PLUGIN_NAME }) - useHideNativeTwitterCard(rootNode, !!profileLink) + useHideNativeTwitterCard(rootNode, !!profileLink, !!isFocusing) if (!profileLink) return null return } -function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean) { +function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean, isFocusing: boolean) { useEffect(() => { if (!rootNode || !enabled) return - // Search from the article (or its parent in detail view, where the card sits in a sibling - // subtree) so we cover both timeline and detail layouts. const article = rootNode.closest('article') - const searchRoot = article?.parentElement ?? rootNode.ownerDocument.body + // Timeline: scope to the article so we don't hide cards in sibling tweets whose Twitter + // preview happens to mention efp.app/ethfollow.xyz in its title or description. Detail + // view: the post's card can live in a sibling subtree of the article (per Twitter's + // postsContentSelector), so widen one level. + const searchRoot = isFocusing ? article?.parentElement : article if (!searchRoot) return const hide = () => { @@ -45,7 +48,7 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean const observer = new MutationObserver(hide) observer.observe(searchRoot, { childList: true, subtree: true }) return () => observer.disconnect() - }, [rootNode, enabled]) + }, [rootNode, enabled, isFocusing]) } // Twitter renders the visible card as a parent that's `aria-labelledby` the card.wrapper id and From 713fb822f19b6b703fff9c1769131f90e2eadeb0 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 20:07:10 +0200 Subject: [PATCH 10/21] fix(efp): clear lint errors in native twitter card hook - Read rootNode/isFocusing via useContext(PostInfoContext) instead of the usePostInfoDetails proxy. The proxy returns plain values for fields like rootNode (no real hook is invoked under the hood) and react-compiler flags the property-access call as 'hook referenced as a normal value'. Reading from the context directly sidesteps the rule and is also one fewer indirection. - Add the 'u' flag to /\\s+/ (require-unicode-regexp). - Use optional chaining on labelledBy.split(...) per @typescript-eslint/prefer-optional-chain. Confirmed clean with 'pnpm exec eslint packages/plugins/EFP --no-cache'. --- .../plugins/EFP/src/SiteAdaptor/index.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 4ce152659b31..0e90c36f002c 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -1,8 +1,13 @@ import { Icons } from '@masknet/icons' -import { type Plugin, usePluginWrapper, usePostInfoDetails } from '@masknet/plugin-infra/content-script' +import { + type Plugin, + PostInfoContext, + usePluginWrapper, + usePostInfoDetails, +} from '@masknet/plugin-infra/content-script' import { parseURLs } from '@masknet/shared-base' import { extractTextFromTypedMessage } from '@masknet/typed-message' -import { useEffect, useMemo, type JSX } from 'react' +import { useContext, useEffect, useMemo, type JSX } from 'react' import { base } from '../base.js' import { PLUGIN_DESCRIPTION, PLUGIN_NAME } from '../constants.js' import { parseEFPProfileLink } from '../helpers/url.js' @@ -10,10 +15,14 @@ import { ProfileCard } from './ProfileCard.js' function Renderer({ url }: { url: string }) { const profileLink = useMemo(() => parseEFPProfileLink(url), [url]) - const rootNode = usePostInfoDetails.rootNode() - const isFocusing = usePostInfoDetails.isFocusing?.() + // Read rootNode/isFocusing through the context directly. The usePostInfoDetails proxy + // also works, but trips react-compiler's hook-as-value rule at the call site for fields + // (like rootNode) that the proxy returns as plain values rather than via a real hook. + const postInfo = useContext(PostInfoContext) + const rootNode = postInfo?.rootNode ?? null + const isFocusing = postInfo?.isFocusing ?? false usePluginWrapper(!!profileLink, { name: PLUGIN_NAME }) - useHideNativeTwitterCard(rootNode, !!profileLink, !!isFocusing) + useHideNativeTwitterCard(rootNode, !!profileLink, isFocusing) if (!profileLink) return null return @@ -58,7 +67,7 @@ function getCardContainer(card: HTMLElement): HTMLElement { const parent = card.parentElement if (!parent || !card.id) return card const labelledBy = parent.getAttribute('aria-labelledby') - if (labelledBy && labelledBy.split(/\s+/).includes(card.id)) return parent + if (labelledBy?.split(/\s+/u).includes(card.id)) return parent return card } From 0b2e129aeeb5d0dd65f8f76b99e4c235b1ff2d82 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 20:07:51 +0200 Subject: [PATCH 11/21] fix(efp): hide native card on link-only tweets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For tweets that are just an EFP link, Twitter's postsContentSelector matches data-testid=card.wrapper directly as the post's rootNode, and the plugin UI mounts in rootElement.afterShadow — a sibling of the card.wrapper, not a descendant. The previous guard skipped hiding any card that contained rootNode, leaving the native preview rendered alongside the EFP card. Replace the skip with a target choice: hide the card itself when the container would also contain rootNode (so we don't take an ancestor — which holds our afterShadow sibling — down with it), and keep hiding the full container otherwise (so the 'From efp.app' footer goes away with the wrapper). --- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 0e90c36f002c..e0f7d4ef446f 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -42,14 +42,16 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean const hide = () => { for (const card of searchRoot.querySelectorAll('[data-testid="card.wrapper"]')) { - // For link-only tweets, rootNode IS the card.wrapper — don't hide our own - // injection target. - if (card.contains(rootNode)) continue if (!isEFPCard(card)) continue const container = getCardContainer(card) - if (container.style.display === 'none') continue - container.style.display = 'none' - container.setAttribute('aria-hidden', 'true') + // For link-only tweets the rootNode IS the card.wrapper, and our React tree mounts + // in rootElement.afterShadow — a sibling of the card. Hiding the card itself is + // fine, but we must not hide any ancestor of rootNode or we'd take our own + // injection down with it. + const target = container.contains(rootNode) ? card : container + if (target.style.display === 'none') continue + target.style.display = 'none' + target.setAttribute('aria-hidden', 'true') } } From 76c40aa65ae4c481a2f57b654e05908a6c8596d3 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 21:08:56 +0200 Subject: [PATCH 12/21] =?UTF-8?q?chore(efp):=20polish=20for=20review=20?= =?UTF-8?q?=E2=80=94=20i18n,=20dedup,=20drop=20completed=20TODOs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap user-visible strings in per repo convention (ProfileCard eyebrow/metrics/footer/link, ApplicationEntries name + description) - Dedup EFP host & reserved-path lists between constants.ts and helpers/url.ts - Dedup host-keyword literals in isEFPCard via EFP_HOST_KEYWORDS - Pass parsed EFPProfileLink from inspectors to Renderer (was parsed twice) - Drop completed TODO list from README --- packages/plugins/EFP/README.md | 7 --- .../EFP/src/SiteAdaptor/ProfileCard.tsx | 31 +++++++---- .../plugins/EFP/src/SiteAdaptor/index.tsx | 54 +++++++++++-------- packages/plugins/EFP/src/constants.ts | 7 ++- packages/plugins/EFP/src/helpers/url.ts | 10 ++-- 5 files changed, 63 insertions(+), 46 deletions(-) diff --git a/packages/plugins/EFP/README.md b/packages/plugins/EFP/README.md index 95e56b8b0e69..45bfa183bd26 100644 --- a/packages/plugins/EFP/README.md +++ b/packages/plugins/EFP/README.md @@ -1,12 +1,5 @@ # Ethereum Follow Protocol plugin -## TODOs - -- Detect direct `ethfollow.xyz` and `efp.app` profile/list links in supported post content. -- Render a compact Ethereum Follow Protocol profile card in Twitter/X posts. -- Fetch profile/list details from the EFP data API and keep a URL-derived fallback card when the API fails. -- Use EFP-generated images for profile and Top 8 previews. - ## Referenced resources - https://efp.app diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx index 47da58ece998..55a7f4151a0c 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -1,8 +1,9 @@ import { Icons } from '@masknet/icons' +import { Trans } from '@lingui/react/macro' import { makeStyles } from '@masknet/theme' import { Box, Link, Stack, Typography } from '@mui/material' -import { useEffect, useMemo, useReducer, useState } from 'react' -import { EFP_API_URL, EFP_FALLBACK_IMAGE_URL, PLUGIN_NAME } from '../constants.js' +import { useEffect, useMemo, useReducer, useState, type ReactNode } from 'react' +import { EFP_API_URL, EFP_FALLBACK_IMAGE_URL } from '../constants.js' import type { EFPProfileLink } from '../helpers/url.js' interface EFPProfileResponse { @@ -131,7 +132,9 @@ export function ProfileCard({ profileLink }: ProfileCardProps) { - {profileLink.topEight ? 'EFP Top 8' : PLUGIN_NAME} + {profileLink.topEight ? + EFP Top 8 + : Ethereum Follow Protocol} {displayName} @@ -142,24 +145,32 @@ export function ProfileCard({ profileLink }: ProfileCardProps) { : null} - - + Followers} + value={loading ? '--' : formatCount(data?.followers_count)} + /> + Following} + value={loading ? '--' : formatCount(data?.following_count)} + /> {primaryList ? - + Primary List} value={`#${primaryList}`} /> : profileLink.type === 'list' ? - + List} value={`#${profileLink.user}`} /> : null} - {profileLink.type === 'list' ? 'EFP list' : 'EFP profile'} + {profileLink.type === 'list' ? + EFP list + : EFP profile} - View on EFP + View on EFP @@ -198,7 +209,7 @@ function ProfileImage({ profileLink }: ProfileCardProps) { ) } -function Metric({ label, value }: { label: string; value: string }) { +function Metric({ label, value }: { label: ReactNode; value: string }) { const { classes } = useStyles() return ( diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index e0f7d4ef446f..b55852eee9d6 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -1,4 +1,5 @@ import { Icons } from '@masknet/icons' +import { Trans } from '@lingui/react/macro' import { type Plugin, PostInfoContext, @@ -9,28 +10,26 @@ import { parseURLs } from '@masknet/shared-base' import { extractTextFromTypedMessage } from '@masknet/typed-message' import { useContext, useEffect, useMemo, type JSX } from 'react' import { base } from '../base.js' -import { PLUGIN_DESCRIPTION, PLUGIN_NAME } from '../constants.js' -import { parseEFPProfileLink } from '../helpers/url.js' +import { EFP_HOST_KEYWORDS, PLUGIN_NAME } from '../constants.js' +import { parseEFPProfileLink, type EFPProfileLink } from '../helpers/url.js' import { ProfileCard } from './ProfileCard.js' -function Renderer({ url }: { url: string }) { - const profileLink = useMemo(() => parseEFPProfileLink(url), [url]) +function Renderer({ profileLink }: { profileLink: EFPProfileLink }) { // Read rootNode/isFocusing through the context directly. The usePostInfoDetails proxy // also works, but trips react-compiler's hook-as-value rule at the call site for fields // (like rootNode) that the proxy returns as plain values rather than via a real hook. const postInfo = useContext(PostInfoContext) const rootNode = postInfo?.rootNode ?? null const isFocusing = postInfo?.isFocusing ?? false - usePluginWrapper(!!profileLink, { name: PLUGIN_NAME }) - useHideNativeTwitterCard(rootNode, !!profileLink, isFocusing) + usePluginWrapper(true, { name: PLUGIN_NAME }) + useHideNativeTwitterCard(rootNode, isFocusing) - if (!profileLink) return null return } -function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean, isFocusing: boolean) { +function useHideNativeTwitterCard(rootNode: HTMLElement | null, isFocusing: boolean) { useEffect(() => { - if (!rootNode || !enabled) return + if (!rootNode) return const article = rootNode.closest('article') // Timeline: scope to the article so we don't hide cards in sibling tweets whose Twitter @@ -59,7 +58,7 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, enabled: boolean const observer = new MutationObserver(hide) observer.observe(searchRoot, { childList: true, subtree: true }) return () => observer.disconnect() - }, [rootNode, enabled, isFocusing]) + }, [rootNode, isFocusing]) } // Twitter renders the visible card as a parent that's `aria-labelledby` the card.wrapper id and @@ -74,41 +73,52 @@ function getCardContainer(card: HTMLElement): HTMLElement { } function isEFPCard(card: HTMLElement) { - if (card.querySelector('a[href*="efp.app"], a[href*="ethfollow.xyz"]')) return true + const anchorSelector = EFP_HOST_KEYWORDS.map((host) => `a[href*="${host}"]`).join(', ') + if (card.querySelector(anchorSelector)) return true for (const el of card.querySelectorAll('[aria-label]')) { const label = el.getAttribute('aria-label')?.toLowerCase() ?? '' - if (label.includes('efp.app') || label.includes('ethfollow.xyz')) return true + if (EFP_HOST_KEYWORDS.some((host) => label.includes(host))) return true } const text = card.textContent?.toLowerCase() ?? '' - return text.includes('efp.app') || text.includes('ethfollow.xyz') + return EFP_HOST_KEYWORDS.some((host) => text.includes(host)) } const site: Plugin.SiteAdaptor.Definition = { ...base, DecryptedInspector(props): JSX.Element | null { - const link = useMemo(() => { + const profileLink = useMemo(() => { const text = extractTextFromTypedMessage(props.message) if (text.isNone()) return null - return parseURLs(text.value, false).find((x) => parseEFPProfileLink(x)) + for (const url of parseURLs(text.value, false)) { + const link = parseEFPProfileLink(url) + if (link) return link + } + return null }, [props.message]) - if (!link) return null - return + if (!profileLink) return null + return }, PostInspector(): JSX.Element | null { const links = usePostInfoDetails.mentionedLinks() - const link = links.find((x) => parseEFPProfileLink(x)) + const profileLink = useMemo(() => { + for (const url of links) { + const link = parseEFPProfileLink(url) + if (link) return link + } + return null + }, [links]) - if (!link) return null - return + if (!profileLink) return null + return }, ApplicationEntries: [ { ApplicationEntryID: base.ID, category: 'dapp', marketListSortingPriority: 18, - description: PLUGIN_DESCRIPTION, - name: PLUGIN_NAME, + description: A native Ethereum protocol for following and tagging Ethereum accounts., + name: Ethereum Follow Protocol, icon: , tutorialLink: 'https://docs.efp.app/intro', }, diff --git a/packages/plugins/EFP/src/constants.ts b/packages/plugins/EFP/src/constants.ts index c3b4747c7c80..728e36a3d699 100644 --- a/packages/plugins/EFP/src/constants.ts +++ b/packages/plugins/EFP/src/constants.ts @@ -7,10 +7,13 @@ export const EFP_APP_URL = 'https://efp.app' export const EFP_API_URL = 'https://data.ethfollow.xyz/api/v1' export const EFP_FALLBACK_IMAGE_URL = `${EFP_APP_URL}/assets/art/default-header.svg` -const RESERVED_ROUTE_PATTERN = - 'api(?:[/?#]|$)|og(?:[/?#]|$)|assets(?:[/?#]|$)|leaderboard(?:[/?#]|$)|integrations(?:[/?#]|$)|team(?:[/?#]|$)|swipe(?:[/?#]|$)' +export const EFP_HOSTS = ['efp.app', 'www.efp.app', 'ethfollow.xyz', 'www.ethfollow.xyz'] as const +export const RESERVED_EFP_PATHS = ['api', 'og', 'assets', 'leaderboard', 'integrations', 'team', 'swipe'] as const +export const EFP_HOST_KEYWORDS = ['efp.app', 'ethfollow.xyz'] as const + const ENS_LABEL_PATTERN = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?' const EFP_USER_PATTERN = `(?:0x[\\dA-Fa-f]{40}|[1-9]\\d*|(?:${ENS_LABEL_PATTERN}\\.)+${ENS_LABEL_PATTERN})` +const RESERVED_ROUTE_PATTERN = RESERVED_EFP_PATHS.map((path) => `${path}(?:[/?#]|$)`).join('|') export const EFP_PROFILE_URL_PATTERN = new RegExp( `^https:\\/\\/(?:www\\.)?(?:ethfollow\\.xyz|efp\\.app)\\/(?!${RESERVED_ROUTE_PATTERN})${EFP_USER_PATTERN}(?:\\?topEight=true)?$`, diff --git a/packages/plugins/EFP/src/helpers/url.ts b/packages/plugins/EFP/src/helpers/url.ts index 11fd60050a6c..a765a577e17a 100644 --- a/packages/plugins/EFP/src/helpers/url.ts +++ b/packages/plugins/EFP/src/helpers/url.ts @@ -1,7 +1,7 @@ -import { EFP_APP_URL } from '../constants.js' +import { EFP_APP_URL, EFP_HOSTS, RESERVED_EFP_PATHS } from '../constants.js' -const EFP_HOSTS = new Set(['efp.app', 'www.efp.app', 'ethfollow.xyz', 'www.ethfollow.xyz']) -const RESERVED_PATHS = new Set(['api', 'og', 'assets', 'leaderboard', 'integrations', 'team', 'swipe']) +const EFP_HOST_SET: ReadonlySet = new Set(EFP_HOSTS) +const RESERVED_PATH_SET: ReadonlySet = new Set(RESERVED_EFP_PATHS) const ADDRESS_PATTERN = /^0x[\dA-Fa-f]{40}$/u const LIST_PATTERN = /^[1-9]\d*$/u const ENS_LABEL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u @@ -19,7 +19,7 @@ export function parseEFPProfileLink(link: string): EFPProfileLink | null { const url = parseURL(link) if (!url) return null if (url.protocol !== 'https:') return null - if (!EFP_HOSTS.has(url.hostname)) return null + if (!EFP_HOST_SET.has(url.hostname)) return null if (url.hash) return null const segments = url.pathname.split('/').filter(Boolean) @@ -27,7 +27,7 @@ export function parseEFPProfileLink(link: string): EFPProfileLink | null { const user = safeDecodeURIComponent(segments[0]) if (!user) return null - if (RESERVED_PATHS.has(user.toLowerCase())) return null + if (RESERVED_PATH_SET.has(user.toLowerCase())) return null if (!isSupportedUser(user)) return null const searchParams = Array.from(url.searchParams.entries()) From a8db34a4ae754613f7d7d1f69340aca3748e4cb3 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 21:30:33 +0200 Subject: [PATCH 13/21] feat(efp): add dedicated EFP icon Replace the generic Icons.Web3Profile placeholder with the EFP brand logo (gold rounded square + arrow + plus mark) at all three call sites: the App entry tile, the post wrapper, and the og-image fallback inside ProfileCard. --- packages/icons/icon-generated-as-jsx.js | 5 +++++ packages/icons/icon-generated-as-url.js | 1 + packages/icons/plugins/EFP.svg | 11 +++++++++++ packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx | 2 +- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 4 ++-- 5 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 packages/icons/plugins/EFP.svg diff --git a/packages/icons/icon-generated-as-jsx.js b/packages/icons/icon-generated-as-jsx.js index ad0d0e08f086..4a144057cdd2 100644 --- a/packages/icons/icon-generated-as-jsx.js +++ b/packages/icons/icon-generated-as-jsx.js @@ -4196,6 +4196,11 @@ export const DecentralizedSearch = /*#__PURE__*/ __createIcon('DecentralizedSear u: () => new URL('./plugins/DecentralizedSearch.svg', import.meta.url).href, }, ]) +export const EFP = /*#__PURE__*/ __createIcon('EFP', [ + { + u: () => new URL('./plugins/EFP.svg', import.meta.url).href, + }, +]) export const ENS = /*#__PURE__*/ __createIcon('ENS', [ { u: () => new URL('./plugins/ENS.png', import.meta.url).href, diff --git a/packages/icons/icon-generated-as-url.js b/packages/icons/icon-generated-as-url.js index babae9c49108..0b4ffd35c81f 100644 --- a/packages/icons/icon-generated-as-url.js +++ b/packages/icons/icon-generated-as-url.js @@ -374,6 +374,7 @@ export function cross_bridge_url() { return new URL("./plugins/CrossBridge.png", export function cyber_connect_dark_url() { return new URL("./plugins/CyberConnect.dark.svg", import.meta.url).href } export function cyber_connect_light_url() { return new URL("./plugins/CyberConnect.light.svg", import.meta.url).href } export function decentralized_search_url() { return new URL("./plugins/DecentralizedSearch.svg", import.meta.url).href } +export function efp_url() { return new URL("./plugins/EFP.svg", import.meta.url).href } export function ens_url() { return new URL("./plugins/ENS.png", import.meta.url).href } export function ens_cover_url() { return new URL("./plugins/ENSCover.svg", import.meta.url).href } export function file_service_url() { return new URL("./plugins/FileService.svg", import.meta.url).href } diff --git a/packages/icons/plugins/EFP.svg b/packages/icons/plugins/EFP.svg new file mode 100644 index 000000000000..33fb22532c5f --- /dev/null +++ b/packages/icons/plugins/EFP.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx index 55a7f4151a0c..957b8a0408d7 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -188,7 +188,7 @@ function ProfileImage({ profileLink }: ProfileCardProps) { if (failed) { return ( - + ) } diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index b55852eee9d6..c68ef6b2f033 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -119,12 +119,12 @@ const site: Plugin.SiteAdaptor.Definition = { marketListSortingPriority: 18, description: A native Ethereum protocol for following and tagging Ethereum accounts., name: Ethereum Follow Protocol, - icon: , + icon: , tutorialLink: 'https://docs.efp.app/intro', }, ], wrapperProps: { - icon: , + icon: , backgroundGradient: 'linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%), linear-gradient(90deg, rgba(255, 224, 103, 0.2) 0%, rgba(211, 234, 244, 0.2) 100%), #FFFFFF', }, From 28edcb4898aec6f36b4fceb624c3dd5a1fd1785f Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 22:09:24 +0200 Subject: [PATCH 14/21] refactor(efp): route data API calls through background RPC Move fetchEFPProfile (and the EFPProfileResponse type) to a Worker module and expose it via PluginEFPRPC, mirroring the CyberConnect pattern. Network requests now run in the background context instead of the content script, sidestepping CORS preflight on the data.ethfollow.xyz origin and aligning with repo convention for external API calls. --- .../EFP/src/SiteAdaptor/ProfileCard.tsx | 27 +++---------------- packages/plugins/EFP/src/Worker/apis/index.ts | 22 +++++++++++++++ packages/plugins/EFP/src/Worker/index.ts | 10 +++++++ packages/plugins/EFP/src/messages.ts | 5 ++++ packages/plugins/EFP/src/register.ts | 4 +++ 5 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 packages/plugins/EFP/src/Worker/apis/index.ts create mode 100644 packages/plugins/EFP/src/Worker/index.ts create mode 100644 packages/plugins/EFP/src/messages.ts diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx index 957b8a0408d7..ad72d6b75733 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -3,19 +3,10 @@ import { Trans } from '@lingui/react/macro' import { makeStyles } from '@masknet/theme' import { Box, Link, Stack, Typography } from '@mui/material' import { useEffect, useMemo, useReducer, useState, type ReactNode } from 'react' -import { EFP_API_URL, EFP_FALLBACK_IMAGE_URL } from '../constants.js' +import { EFP_FALLBACK_IMAGE_URL } from '../constants.js' import type { EFPProfileLink } from '../helpers/url.js' - -interface EFPProfileResponse { - address?: string - ens?: { - name?: string | null - records?: Record | null - } | null - followers_count?: number | string - following_count?: number | string - primary_list?: string | null -} +import { PluginEFPRPC } from '../messages.js' +import type { EFPProfileResponse } from '../Worker/apis/index.js' interface ProfileCardProps { profileLink: EFPProfileLink @@ -233,7 +224,7 @@ function useEFPProfile(profileLink: EFPProfileLink) { let cancelled = false dispatch({ type: 'loading' }) - fetchEFPProfile(profileLink.apiPath) + PluginEFPRPC.fetchEFPProfile(profileLink.apiPath) .then((data) => { if (cancelled) return dispatch({ type: 'success', data: isProfileResponse(data) ? data : null }) @@ -257,16 +248,6 @@ function reduceEFPProfileState(_: EFPProfileState, action: EFPProfileAction): EF return { data: null, loading: false } } -async function fetchEFPProfile(apiPath: string) { - const response = await fetch(`${EFP_API_URL}${apiPath}`, { - headers: { - Accept: 'application/json', - }, - }) - if (!response.ok) throw new Error('Failed to fetch EFP profile') - return response.json() as Promise -} - function isProfileResponse(value: EFPProfileResponse | null): value is EFPProfileResponse { return !!value && (typeof value.address === 'string' || typeof value.primary_list === 'string') } diff --git a/packages/plugins/EFP/src/Worker/apis/index.ts b/packages/plugins/EFP/src/Worker/apis/index.ts new file mode 100644 index 000000000000..5950e9c7d41c --- /dev/null +++ b/packages/plugins/EFP/src/Worker/apis/index.ts @@ -0,0 +1,22 @@ +import { EFP_API_URL } from '../../constants.js' + +export interface EFPProfileResponse { + address?: string + ens?: { + name?: string | null + records?: Record | null + } | null + followers_count?: number | string + following_count?: number | string + primary_list?: string | null +} + +export async function fetchEFPProfile(apiPath: string): Promise { + const response = await fetch(`${EFP_API_URL}${apiPath}`, { + headers: { + Accept: 'application/json', + }, + }) + if (!response.ok) throw new Error('Failed to fetch EFP profile') + return response.json() as Promise +} diff --git a/packages/plugins/EFP/src/Worker/index.ts b/packages/plugins/EFP/src/Worker/index.ts new file mode 100644 index 000000000000..2c2b03ab7fd5 --- /dev/null +++ b/packages/plugins/EFP/src/Worker/index.ts @@ -0,0 +1,10 @@ +import type { Plugin } from '@masknet/plugin-infra' +import { base } from '../base.js' + +const worker: Plugin.Worker.Definition = { + ...base, + init(signal, context) { + context.startService(import('./apis/index.js')) + }, +} +export default worker diff --git a/packages/plugins/EFP/src/messages.ts b/packages/plugins/EFP/src/messages.ts new file mode 100644 index 000000000000..f76bc272a9a3 --- /dev/null +++ b/packages/plugins/EFP/src/messages.ts @@ -0,0 +1,5 @@ +import { getPluginRPC } from '@masknet/plugin-infra' +import { PLUGIN_ID } from './constants.js' + +import.meta.webpackHot?.accept() +export const PluginEFPRPC = getPluginRPC(PLUGIN_ID) diff --git a/packages/plugins/EFP/src/register.ts b/packages/plugins/EFP/src/register.ts index b163fd4dfd9f..aa1f8a4cd9d5 100644 --- a/packages/plugins/EFP/src/register.ts +++ b/packages/plugins/EFP/src/register.ts @@ -8,4 +8,8 @@ registerPlugin({ hotModuleReload: (hot) => import.meta.webpackHot?.accept('./SiteAdaptor', () => hot(import('./SiteAdaptor/index.js'))), }, + Worker: { + load: () => import('./Worker/index.js'), + hotModuleReload: (hot) => import.meta.webpackHot?.accept('./Worker', () => hot(import('./Worker/index.js'))), + }, }) From edef341649dd26c7a2d4079cb035582a2b5ccbc0 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 22:40:54 +0200 Subject: [PATCH 15/21] fix(efp): match protocol-less EFP links in PostInspector X often renders link text without a scheme (efp.app/vitalik.eth). mentionedLinks() requires URL.canParse (i.e. a protocol), so those get dropped before parseEFPProfileLink can see them. Switch to rawMessage() + parseURLs(text, false), matching the DecryptedInspector in the same file and the rawMessage pattern used by NextID and ScamSniffer. --- packages/plugins/EFP/src/SiteAdaptor/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index c68ef6b2f033..0b4cbb96db6f 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -100,14 +100,16 @@ const site: Plugin.SiteAdaptor.Definition = { return }, PostInspector(): JSX.Element | null { - const links = usePostInfoDetails.mentionedLinks() + const message = usePostInfoDetails.rawMessage() const profileLink = useMemo(() => { - for (const url of links) { + const text = extractTextFromTypedMessage(message) + if (text.isNone()) return null + for (const url of parseURLs(text.value, false)) { const link = parseEFPProfileLink(url) if (link) return link } return null - }, [links]) + }, [message]) if (!profileLink) return null return From 1ea89169b75991092b5d7ec195b36cc0d7157fb4 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Wed, 29 Apr 2026 22:40:59 +0200 Subject: [PATCH 16/21] chore: whitelist efprpc in cspell.json cspell tokenises PluginEFPRPC as Plugin / EFPRPC (consecutive caps stay in one block), and EFPRPC isn't in any default dictionary. Add it to ignoreWords in alphabetical order. --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 6149cd7013a5..b0c44f24f3af 100644 --- a/cspell.json +++ b/cspell.json @@ -76,6 +76,7 @@ "dompurify", "dotbit", "dsearch", + "efprpc", "enhanceable", "ethfollow", "evmos", From 80ca3e88c6385a9ba1ba2be0ed0cb2242e0d3398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nejc=20Drobni=C4=8D?= Date: Sun, 10 May 2026 18:23:59 +0200 Subject: [PATCH 17/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/plugins/EFP/src/Worker/apis/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/plugins/EFP/src/Worker/apis/index.ts b/packages/plugins/EFP/src/Worker/apis/index.ts index 5950e9c7d41c..55766fcb18d1 100644 --- a/packages/plugins/EFP/src/Worker/apis/index.ts +++ b/packages/plugins/EFP/src/Worker/apis/index.ts @@ -12,11 +12,14 @@ export interface EFPProfileResponse { } export async function fetchEFPProfile(apiPath: string): Promise { - const response = await fetch(`${EFP_API_URL}${apiPath}`, { + const url = `${EFP_API_URL}${apiPath}` + const response = await fetch(url, { headers: { Accept: 'application/json', }, }) - if (!response.ok) throw new Error('Failed to fetch EFP profile') + if (!response.ok) { + throw new Error(`Failed to fetch EFP profile from ${apiPath} (status: ${response.status})`) + } return response.json() as Promise } From 528824fdaddc1ded961ce8f5e9f4eae42c401cee Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 11 May 2026 22:34:29 +0200 Subject: [PATCH 18/21] fix(efp): restore native Twitter card when the plugin unmounts useHideNativeTwitterCard sets display:none and aria-hidden=true on the native Twitter card but the cleanup only disconnected the MutationObserver, so on unmount (navigation, plugin disabled, post leaving the viewport) the card stayed hidden with no way back. Track each modified element with its previous display/aria-hidden values and revert them on cleanup. Skip elements we've already hidden so re-firings of the observer don't overwrite the stored previous state. --- .../plugins/EFP/src/SiteAdaptor/index.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 0b4cbb96db6f..29d8fbc500a0 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -39,6 +39,11 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, isFocusing: bool const searchRoot = isFocusing ? article?.parentElement : article if (!searchRoot) return + // Track every element we modify so the cleanup can restore the Twitter card if the plugin + // unmounts (navigation, post leaves the viewport, plugin disabled). Without this, hidden + // cards stay hidden forever even after the React tree is gone. + const modified = new Map() + const hide = () => { for (const card of searchRoot.querySelectorAll('[data-testid="card.wrapper"]')) { if (!isEFPCard(card)) continue @@ -48,7 +53,11 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, isFocusing: bool // fine, but we must not hide any ancestor of rootNode or we'd take our own // injection down with it. const target = container.contains(rootNode) ? card : container - if (target.style.display === 'none') continue + if (modified.has(target)) continue + modified.set(target, { + display: target.style.display, + ariaHidden: target.getAttribute('aria-hidden'), + }) target.style.display = 'none' target.setAttribute('aria-hidden', 'true') } @@ -57,7 +66,15 @@ function useHideNativeTwitterCard(rootNode: HTMLElement | null, isFocusing: bool hide() const observer = new MutationObserver(hide) observer.observe(searchRoot, { childList: true, subtree: true }) - return () => observer.disconnect() + return () => { + observer.disconnect() + for (const [target, prev] of modified) { + target.style.display = prev.display + if (prev.ariaHidden === null) target.removeAttribute('aria-hidden') + else target.setAttribute('aria-hidden', prev.ariaHidden) + } + modified.clear() + } }, [rootNode, isFocusing]) } From 4c6fe360258f31e0ecd5fb8a1ea5152c52771a2c Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 11 May 2026 22:37:06 +0200 Subject: [PATCH 19/21] fix(efp): match cards by parsed link, not substring isEFPCard used a[href*="efp.app"] and lowercase substring scans of aria-label and textContent. That would hide any Twitter card whose title or description happens to mention efp.app, or whose host contains the substring. Walk each anchor inside the card and run its href, visible text, and aria-label through parseEFPProfileLink. Only valid EFP profile/list URLs match, which also handles t.co-wrapped hrefs (the real URL surfaces as the anchor's display text). Drops the now-unused EFP_HOST_KEYWORDS export. --- .../plugins/EFP/src/SiteAdaptor/index.tsx | 21 ++++++++++++------- packages/plugins/EFP/src/constants.ts | 1 - 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx index 29d8fbc500a0..a792dd43f3ce 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/index.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -10,7 +10,7 @@ import { parseURLs } from '@masknet/shared-base' import { extractTextFromTypedMessage } from '@masknet/typed-message' import { useContext, useEffect, useMemo, type JSX } from 'react' import { base } from '../base.js' -import { EFP_HOST_KEYWORDS, PLUGIN_NAME } from '../constants.js' +import { PLUGIN_NAME } from '../constants.js' import { parseEFPProfileLink, type EFPProfileLink } from '../helpers/url.js' import { ProfileCard } from './ProfileCard.js' @@ -89,15 +89,20 @@ function getCardContainer(card: HTMLElement): HTMLElement { return card } +// Twitter wraps external links in t.co redirects, so the anchor href is usually opaque. The +// real EFP URL surfaces as the anchor's visible text or as its aria-label. Run all three +// through parseEFPProfileLink so we only match valid EFP profile/list URLs — substring checks +// would false-positive on cards whose description merely mentions efp.app, or on hostnames +// that happen to contain it as a substring. function isEFPCard(card: HTMLElement) { - const anchorSelector = EFP_HOST_KEYWORDS.map((host) => `a[href*="${host}"]`).join(', ') - if (card.querySelector(anchorSelector)) return true - for (const el of card.querySelectorAll('[aria-label]')) { - const label = el.getAttribute('aria-label')?.toLowerCase() ?? '' - if (EFP_HOST_KEYWORDS.some((host) => label.includes(host))) return true + for (const anchor of card.querySelectorAll('a[href]')) { + if (parseEFPProfileLink(anchor.href)) return true + const text = anchor.textContent?.trim() + if (text && parseEFPProfileLink(text)) return true + const label = anchor.getAttribute('aria-label')?.trim() + if (label && parseEFPProfileLink(label)) return true } - const text = card.textContent?.toLowerCase() ?? '' - return EFP_HOST_KEYWORDS.some((host) => text.includes(host)) + return false } const site: Plugin.SiteAdaptor.Definition = { diff --git a/packages/plugins/EFP/src/constants.ts b/packages/plugins/EFP/src/constants.ts index 728e36a3d699..fcf19874b3bb 100644 --- a/packages/plugins/EFP/src/constants.ts +++ b/packages/plugins/EFP/src/constants.ts @@ -9,7 +9,6 @@ export const EFP_FALLBACK_IMAGE_URL = `${EFP_APP_URL}/assets/art/default-header. export const EFP_HOSTS = ['efp.app', 'www.efp.app', 'ethfollow.xyz', 'www.ethfollow.xyz'] as const export const RESERVED_EFP_PATHS = ['api', 'og', 'assets', 'leaderboard', 'integrations', 'team', 'swipe'] as const -export const EFP_HOST_KEYWORDS = ['efp.app', 'ethfollow.xyz'] as const const ENS_LABEL_PATTERN = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?' const EFP_USER_PATTERN = `(?:0x[\\dA-Fa-f]{40}|[1-9]\\d*|(?:${ENS_LABEL_PATTERN}\\.)+${ENS_LABEL_PATTERN})` From 47958d7722c04188abd927ea44ac376e190cfce4 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Mon, 11 May 2026 22:38:22 +0200 Subject: [PATCH 20/21] refactor(efp): use useQuery for profile data fetching Replace the hand-rolled useReducer + useEffect + cancellation flag with @tanstack/react-query (already a workspace dep, used by CyberConnect the same way). The select callback narrows the EFPProfileResponse via isProfileResponse so the rest of ProfileCard keeps its existing data?.foo access shape. Drops the EFPProfileState / EFPProfileAction types, reduceEFPProfileState and the useEFPProfile wrapper. getDisplayName now also accepts undefined since useQuery's data is undefined during the initial load. --- .../EFP/src/SiteAdaptor/ProfileCard.tsx | 55 +++---------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx index ad72d6b75733..b8cb9fd4078b 100644 --- a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -2,7 +2,8 @@ import { Icons } from '@masknet/icons' import { Trans } from '@lingui/react/macro' import { makeStyles } from '@masknet/theme' import { Box, Link, Stack, Typography } from '@mui/material' -import { useEffect, useMemo, useReducer, useState, type ReactNode } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useMemo, useState, type ReactNode } from 'react' import { EFP_FALLBACK_IMAGE_URL } from '../constants.js' import type { EFPProfileLink } from '../helpers/url.js' import { PluginEFPRPC } from '../messages.js' @@ -12,16 +13,6 @@ interface ProfileCardProps { profileLink: EFPProfileLink } -interface EFPProfileState { - data: EFPProfileResponse | null - loading: boolean -} - -type EFPProfileAction = - | { type: 'loading' } - | { type: 'success'; data: EFPProfileResponse | null } - | { type: 'error' } - const formatter = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1, @@ -112,7 +103,11 @@ const useStyles = makeStyles()((theme) => ({ export function ProfileCard({ profileLink }: ProfileCardProps) { const { classes } = useStyles() - const { data, loading } = useEFPProfile(profileLink) + const { data, isPending: loading } = useQuery({ + queryKey: ['efp', 'profile', profileLink.apiPath], + queryFn: () => PluginEFPRPC.fetchEFPProfile(profileLink.apiPath), + select: (raw) => (isProfileResponse(raw) ? raw : null), + }) const displayName = useMemo(() => getDisplayName(profileLink, data), [profileLink, data]) const description = data?.ens?.records?.description const primaryList = data?.primary_list @@ -214,45 +209,11 @@ function Metric({ label, value }: { label: ReactNode; value: string }) { ) } -function useEFPProfile(profileLink: EFPProfileLink) { - const [state, dispatch] = useReducer(reduceEFPProfileState, { - data: null, - loading: true, - }) - - useEffect(() => { - let cancelled = false - dispatch({ type: 'loading' }) - - PluginEFPRPC.fetchEFPProfile(profileLink.apiPath) - .then((data) => { - if (cancelled) return - dispatch({ type: 'success', data: isProfileResponse(data) ? data : null }) - }) - .catch(() => { - if (cancelled) return - dispatch({ type: 'error' }) - }) - - return () => { - cancelled = true - } - }, [profileLink.apiPath]) - - return state -} - -function reduceEFPProfileState(_: EFPProfileState, action: EFPProfileAction): EFPProfileState { - if (action.type === 'loading') return { data: null, loading: true } - if (action.type === 'success') return { data: action.data, loading: false } - return { data: null, loading: false } -} - function isProfileResponse(value: EFPProfileResponse | null): value is EFPProfileResponse { return !!value && (typeof value.address === 'string' || typeof value.primary_list === 'string') } -function getDisplayName(profileLink: EFPProfileLink, data: EFPProfileResponse | null) { +function getDisplayName(profileLink: EFPProfileLink, data: EFPProfileResponse | null | undefined) { const ensName = data?.ens?.name if (ensName) return ensName if (profileLink.type === 'list') return `List #${profileLink.user}` From e9574336079500e80b37ea0aab09fac30344829c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nejc=20Drobni=C4=8D?= Date: Tue, 12 May 2026 10:53:04 +0200 Subject: [PATCH 21/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/plugins/EFP/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/EFP/src/constants.ts b/packages/plugins/EFP/src/constants.ts index fcf19874b3bb..dca95fc7f084 100644 --- a/packages/plugins/EFP/src/constants.ts +++ b/packages/plugins/EFP/src/constants.ts @@ -15,6 +15,6 @@ const EFP_USER_PATTERN = `(?:0x[\\dA-Fa-f]{40}|[1-9]\\d*|(?:${ENS_LABEL_PATTERN} const RESERVED_ROUTE_PATTERN = RESERVED_EFP_PATHS.map((path) => `${path}(?:[/?#]|$)`).join('|') export const EFP_PROFILE_URL_PATTERN = new RegExp( - `^https:\\/\\/(?:www\\.)?(?:ethfollow\\.xyz|efp\\.app)\\/(?!${RESERVED_ROUTE_PATTERN})${EFP_USER_PATTERN}(?:\\?topEight=true)?$`, + `^(?:https:\\/\\/)?(?:www\\.)?(?:ethfollow\\.xyz|efp\\.app)\\/(?!${RESERVED_ROUTE_PATTERN})${EFP_USER_PATTERN}(?:\\?topEight=true)?$`, 'u', )