diff --git a/cspell.json b/cspell.json index 0d984fa15b20..b0c44f24f3af 100644 --- a/cspell.json +++ b/cspell.json @@ -76,7 +76,9 @@ "dompurify", "dotbit", "dsearch", + "efprpc", "enhanceable", + "ethfollow", "evmos", "farcaster", "favourites", 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/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/README.md b/packages/plugins/EFP/README.md new file mode 100644 index 000000000000..45bfa183bd26 --- /dev/null +++ b/packages/plugins/EFP/README.md @@ -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. diff --git a/packages/plugins/EFP/package.json b/packages/plugins/EFP/package.json new file mode 100644 index 000000000000..ec4d05eaeaf0 --- /dev/null +++ b/packages/plugins/EFP/package.json @@ -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:^" + } +} diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx new file mode 100644 index 000000000000..b8cb9fd4078b --- /dev/null +++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx @@ -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 ( + + + + + + {profileLink.topEight ? + EFP Top 8 + : Ethereum Follow Protocol} + + + {displayName} + + {description ? + + {description} + + : 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} + + + View on EFP + + + + + + + ) +} + +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: ReactNode; value: string }) { + const { classes } = useStyles() + return ( + + + {value} + + + {label} + + + ) +} + +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) +} diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx new file mode 100644 index 000000000000..a792dd43f3ce --- /dev/null +++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx @@ -0,0 +1,157 @@ +import { Icons } from '@masknet/icons' +import { Trans } from '@lingui/react/macro' +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 { useContext, useEffect, useMemo, type JSX } from 'react' +import { base } from '../base.js' +import { PLUGIN_NAME } from '../constants.js' +import { parseEFPProfileLink, type EFPProfileLink } from '../helpers/url.js' +import { ProfileCard } from './ProfileCard.js' + +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(true, { name: PLUGIN_NAME }) + useHideNativeTwitterCard(rootNode, isFocusing) + + return +} + +function useHideNativeTwitterCard(rootNode: HTMLElement | null, isFocusing: boolean) { + useEffect(() => { + if (!rootNode) return + + const article = rootNode.closest('article') + // 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 + + // 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 + 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 (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') + } + } + + hide() + const observer = new MutationObserver(hide) + observer.observe(searchRoot, { childList: true, subtree: true }) + 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]) +} + +// 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?.split(/\s+/u).includes(card.id)) return parent + 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) { + 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 + } + return false +} + +const site: Plugin.SiteAdaptor.Definition = { + ...base, + DecryptedInspector(props): JSX.Element | null { + const profileLink = useMemo(() => { + const text = extractTextFromTypedMessage(props.message) + if (text.isNone()) return null + for (const url of parseURLs(text.value, false)) { + const link = parseEFPProfileLink(url) + if (link) return link + } + return null + }, [props.message]) + + if (!profileLink) return null + return + }, + PostInspector(): JSX.Element | null { + const message = usePostInfoDetails.rawMessage() + const profileLink = useMemo(() => { + 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 + }, [message]) + + if (!profileLink) return null + return + }, + ApplicationEntries: [ + { + ApplicationEntryID: base.ID, + category: 'dapp', + marketListSortingPriority: 18, + description: A native Ethereum protocol for following and tagging Ethereum accounts., + name: Ethereum Follow Protocol, + 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/src/Worker/apis/index.ts b/packages/plugins/EFP/src/Worker/apis/index.ts new file mode 100644 index 000000000000..55766fcb18d1 --- /dev/null +++ b/packages/plugins/EFP/src/Worker/apis/index.ts @@ -0,0 +1,25 @@ +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 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 from ${apiPath} (status: ${response.status})`) + } + 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/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..dca95fc7f084 --- /dev/null +++ b/packages/plugins/EFP/src/constants.ts @@ -0,0 +1,20 @@ +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' +export const EFP_FALLBACK_IMAGE_URL = `${EFP_APP_URL}/assets/art/default-header.svg` + +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 + +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)?$`, + '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..a765a577e17a --- /dev/null +++ b/packages/plugins/EFP/src/helpers/url.ts @@ -0,0 +1,78 @@ +import { EFP_APP_URL, EFP_HOSTS, RESERVED_EFP_PATHS } from '../constants.js' + +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 + +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_HOST_SET.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_PATH_SET.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/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 new file mode 100644 index 000000000000..aa1f8a4cd9d5 --- /dev/null +++ b/packages/plugins/EFP/src/register.ts @@ -0,0 +1,15 @@ +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'))), + }, + Worker: { + load: () => import('./Worker/index.js'), + hotModuleReload: (hot) => import.meta.webpackHot?.accept('./Worker', () => hot(import('./Worker/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..909554b29dc5 --- /dev/null +++ b/packages/plugins/EFP/tsconfig.json @@ -0,0 +1,15 @@ +{ + "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" }, + { "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/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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86e8a1c85b3f..c83dcf2b74d9 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/" ],