Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
787a881
feat(efp): recognize profile links
Quantumlyy Apr 25, 2026
05d30d5
feat(efp): render twitter embeds
Quantumlyy Apr 26, 2026
abf12fe
fix(efp): hide native twitter preview
Quantumlyy Apr 27, 2026
5d9b7bb
fix(efp): scope native twitter card hiding to post root
Quantumlyy Apr 27, 2026
1aab157
fix(efp): query card.wrapper from the tweet, not from rootNode
Quantumlyy Apr 27, 2026
e22513a
fix(efp): detect via aria-label and hide card container
Quantumlyy Apr 27, 2026
e973299
fix(efp): widen search root to article parent
Quantumlyy Apr 28, 2026
925c106
fix(efp): use card.contains(rootNode) to skip own injection target
Quantumlyy Apr 28, 2026
e92dd63
fix(efp): scope to article in timeline view
Quantumlyy Apr 28, 2026
713fb82
fix(efp): clear lint errors in native twitter card hook
Quantumlyy Apr 29, 2026
0b2e129
fix(efp): hide native card on link-only tweets
Quantumlyy Apr 29, 2026
76c40aa
chore(efp): polish for review — i18n, dedup, drop completed TODOs
Quantumlyy Apr 29, 2026
a8db34a
feat(efp): add dedicated EFP icon
Quantumlyy Apr 29, 2026
28edcb4
refactor(efp): route data API calls through background RPC
Quantumlyy Apr 29, 2026
edef341
fix(efp): match protocol-less EFP links in PostInspector
Quantumlyy Apr 29, 2026
1ea8916
chore: whitelist efprpc in cspell.json
Quantumlyy Apr 29, 2026
43d5b53
Merge branch 'develop' into ethfollow-twitter-embeds
Quantumlyy May 10, 2026
80ca3e8
Potential fix for pull request finding
Quantumlyy May 10, 2026
798a756
Merge branch 'develop' into ethfollow-twitter-embeds
Quantumlyy May 10, 2026
528824f
fix(efp): restore native Twitter card when the plugin unmounts
Quantumlyy May 11, 2026
4c6fe36
fix(efp): match cards by parsed link, not substring
Quantumlyy May 11, 2026
47958d7
refactor(efp): use useQuery for profile data fetching
Quantumlyy May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@
"dompurify",
"dotbit",
"dsearch",
"efprpc",
"enhanceable",
"ethfollow",
"evmos",
"farcaster",
"favourites",
Expand Down
5 changes: 5 additions & 0 deletions packages/icons/icon-generated-as-jsx.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/icons/icon-generated-as-url.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions packages/icons/plugins/EFP.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/mask/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
1 change: 1 addition & 0 deletions packages/mask/shared/plugin-infra/register.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/EFP/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Ethereum Follow Protocol plugin

## 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.
25 changes: 25 additions & 0 deletions packages/plugins/EFP/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"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/icons": "workspace:^",
"@masknet/plugin-infra": "workspace:^",
"@masknet/shared-base": "workspace:^",
"@masknet/theme": "workspace:^",
"@masknet/typed-message": "workspace:^"
}
}
232 changes: 232 additions & 0 deletions packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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 { 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'
import type { EFPProfileResponse } from '../Worker/apis/index.js'

interface ProfileCardProps {
profileLink: EFPProfileLink
}

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,
},
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),
},
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, 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

return (
<Stack className={classes.root}>
<Box className={classes.card}>
<ProfileImage key={profileLink.imageUrl} profileLink={profileLink} />
<Stack className={classes.body}>
<Typography className={classes.eyebrow} variant="caption">
{profileLink.topEight ?
<Trans>EFP Top 8</Trans>
: <Trans>Ethereum Follow Protocol</Trans>}
</Typography>
<Typography className={classes.title} variant="h6">
{displayName}
</Typography>
{description ?
<Typography className={classes.description} variant="body2">
{description}
</Typography>
: null}
<Box className={classes.metrics}>
<Metric
label={<Trans>Followers</Trans>}
value={loading ? '--' : formatCount(data?.followers_count)}
/>
<Metric
label={<Trans>Following</Trans>}
value={loading ? '--' : formatCount(data?.following_count)}
/>
{primaryList ?
<Metric label={<Trans>Primary List</Trans>} value={`#${primaryList}`} />
: profileLink.type === 'list' ?
<Metric label={<Trans>List</Trans>} value={`#${profileLink.user}`} />
: null}
</Box>
<Box className={classes.footer}>
<Typography variant="caption" color="textSecondary">
{profileLink.type === 'list' ?
<Trans>EFP list</Trans>
: <Trans>EFP profile</Trans>}
</Typography>
<Link
className={classes.link}
href={profileLink.profileUrl}
target="_blank"
rel="noopener noreferrer">
<Trans>View on EFP</Trans>
<Icons.LinkOut size={14} />
</Link>
</Box>
</Stack>
</Box>
</Stack>
)
}

function ProfileImage({ profileLink }: ProfileCardProps) {
const { classes } = useStyles()
const [imageUrl, setImageUrl] = useState(profileLink.imageUrl)
const [failed, setFailed] = useState(false)

if (failed) {
return (
<Box className={classes.imageFallback}>
<Icons.EFP size={64} />
</Box>
)
}

return (
<img
className={classes.image}
src={imageUrl}
alt=""
onError={() => {
if (imageUrl === EFP_FALLBACK_IMAGE_URL) {
setFailed(true)
return
}
setImageUrl(EFP_FALLBACK_IMAGE_URL)
}}
/>
)
}

function Metric({ label, value }: { label: ReactNode; value: string }) {
const { classes } = useStyles()
return (
<Box className={classes.metric}>
<Typography className={classes.metricValue} variant="body2">
{value}
</Typography>
<Typography className={classes.metricLabel} variant="caption">
{label}
</Typography>
</Box>
)
}

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 | undefined) {
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)
}
Loading
Loading