Skip to content

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

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

feat(efp): add Ethereum Follow Protocol plugin#12377
Quantumlyy wants to merge 16 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
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.

1 participant