Skip to content

feat(efp): add Ethereum Follow Protocol plugin#12377

Open
Quantumlyy wants to merge 19 commits intoDimensionDev:developfrom
Quantumlyy:ethfollow-twitter-embeds
Open

feat(efp): add Ethereum Follow Protocol plugin#12377
Quantumlyy wants to merge 19 commits intoDimensionDev:developfrom
Quantumlyy:ethfollow-twitter-embeds

Conversation

@Quantumlyy
Copy link
Copy Markdown

@Quantumlyy Quantumlyy commented Apr 29, 2026

Description

New plugin (@masknet/plugin-efp) for Ethereum Follow Protocol. Detects efp.app and ethfollow.xyz profile/list links in Twitter posts (with optional ?topEight=true) and renders an inline card with ENS name, description, follower/following counts, and a "View on EFP" link. Data comes from data.ethfollow.xyz/api/v1, with a fallback when the API is unreachable.

Also hides the native Twitter card for tweets that link to EFP so we don't end up with two embeds for the same URL. Hide is scoped to the article in the timeline and widened by one level in the detail view; there's a guard for link-only tweets where the rootNode is the card itself.

External API calls go through a Worker + PluginEFPRPC, mirroring the CyberConnect plugin. Added data.ethfollow.xyz to connect-src in the CSP. Dedicated EFP icon registered in @masknet/icons.

No new dependencies.

Type of change

  • New feature (non-breaking change which adds functionality)

Previews

image

Checklist

  • My code follows the style guidelines of this project.
  • I have performed a self-review of my own code.
    • I have removed all in development `console.log`s
    • I have removed all commented code.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have read Internationalization Guide and moved text fields to the i18n JSON file.
    • This fork uses lingui `` macros, not per-plugin JSON files. All user-visible strings go through ``.

If this PR depends on external APIs:

  • I have configured those APIs with CORS headers to let extension requests get passed.
    • EFP API echoes `Origin`, so extension origins are accepted. Routing through the background sidesteps it anyway.
  • I have delegated all web requests to the background service via the internal RPC bridge.
    • `PluginEFPRPC.fetchEFPProfile` in `src/messages.ts`, served from `src/Worker/apis/index.ts`.

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.
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.
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.
[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.
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.
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.
- 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'.
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).
- Wrap user-visible strings in <Trans> 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
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.
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.
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.
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.
@Quantumlyy Quantumlyy marked this pull request as ready for review April 30, 2026 16:04
Copilot AI review requested due to automatic review settings May 10, 2026 12:53
@Quantumlyy
Copy link
Copy Markdown
Author

Hello, any updates?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new @masknet/plugin-efp plugin to embed Ethereum Follow Protocol (EFP) profile/list links in supported posts, including a Worker/RPC bridge for API calls and an EFP icon/CSP updates to support required network/image access.

Changes:

  • Introduces the EFP plugin package (SiteAdaptor UI, Worker APIs, URL parsing, and Vitest URL tests).
  • Registers the plugin in the Mask app and workspace, and assigns a new PluginID.
  • Adds an EFP icon to @masknet/icons and extends CSP to allow EFP API fetches and preview images.

Reviewed changes

Copilot reviewed 22 out of 25 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
security/content-security-policy.json Allows EFP API origin in connect-src and EFP app origin in img-src.
pnpm-lock.yaml Adds the new workspace plugin entry for @masknet/plugin-efp.
packages/shared-base/src/types/PluginID.ts Introduces PluginID.EFP.
packages/plugins/tsconfig.json Adds project reference for the new EFP plugin.
packages/plugins/EFP/tsconfig.json New TS config for building the EFP plugin package.
packages/plugins/EFP/src/Worker/index.ts Worker entry that starts the service API module.
packages/plugins/EFP/src/Worker/apis/index.ts Worker-side fetch API for EFP profile/list details.
packages/plugins/EFP/src/tests/url.ts Vitest coverage for URL parsing and contribution URL regex behavior.
packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx Renders the inline EFP card UI and loads profile data via RPC.
packages/plugins/EFP/src/SiteAdaptor/index.tsx Detects EFP links in posts and hides native Twitter cards to avoid duplicate embeds.
packages/plugins/EFP/src/register.ts Registers SiteAdaptor and Worker loaders (with HMR hooks).
packages/plugins/EFP/src/messages.ts Defines PluginEFPRPC RPC bridge to the Worker APIs.
packages/plugins/EFP/src/index.ts Exports plugin constants and URL helpers.
packages/plugins/EFP/src/helpers/url.ts Parses/validates EFP profile/list links and derives profile/image/API URLs.
packages/plugins/EFP/src/env.d.ts Adds webpack HMR global types reference.
packages/plugins/EFP/src/constants.ts Defines plugin metadata, EFP hosts, and URL matching regex.
packages/plugins/EFP/src/base.ts Declares plugin base definition + contribution matcher.
packages/plugins/EFP/README.md Documents referenced resources and parsing constraints/caveats.
packages/plugins/EFP/package.json Declares the new plugin package exports and workspace deps.
packages/mask/shared/plugin-infra/register.js Registers the EFP plugin in the app’s plugin registry.
packages/mask/package.json Adds @masknet/plugin-efp as a workspace dependency.
packages/icons/plugins/EFP.svg Adds the EFP icon asset.
packages/icons/icon-generated-as-url.js Exposes the EFP icon as a URL export.
packages/icons/icon-generated-as-jsx.js Exposes the EFP icon as a JSX icon export.
cspell.json Adds “efprpc” / “ethfollow” to the spelling dictionary.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/plugins/EFP/src/SiteAdaptor/index.tsx
Comment thread packages/plugins/EFP/src/SiteAdaptor/index.tsx
Comment thread packages/plugins/EFP/src/Worker/apis/index.ts Outdated
Quantumlyy and others added 2 commits May 10, 2026 18:23
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 25 changed files in this pull request and generated 2 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment on lines +42 to +60
const hide = () => {
for (const card of searchRoot.querySelectorAll<HTMLElement>('[data-testid="card.wrapper"]')) {
if (!isEFPCard(card)) continue
const container = getCardContainer(card)
// 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')
}
}

hide()
const observer = new MutationObserver(hide)
observer.observe(searchRoot, { childList: true, subtree: true })
return () => observer.disconnect()
Comment on lines +76 to +83
const anchorSelector = EFP_HOST_KEYWORDS.map((host) => `a[href*="${host}"]`).join(', ')
if (card.querySelector(anchorSelector)) return true
for (const el of card.querySelectorAll<HTMLElement>('[aria-label]')) {
const label = el.getAttribute('aria-label')?.toLowerCase() ?? ''
if (EFP_HOST_KEYWORDS.some((host) => label.includes(host))) return true
}
const text = card.textContent?.toLowerCase() ?? ''
return EFP_HOST_KEYWORDS.some((host) => text.includes(host))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants