From d9cd9b4f291bfc9ca2a565960f32f33b6f26a46b Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 2 Jun 2026 11:42:12 +0200 Subject: [PATCH 01/23] feat(efp): add EFP API client and types --- src/lib/utils/efp/index.ts | 141 +++++++++++++++++++++++++++ src/lib/utils/efp/index.unit.test.ts | 55 +++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/lib/utils/efp/index.ts create mode 100644 src/lib/utils/efp/index.unit.test.ts diff --git a/src/lib/utils/efp/index.ts b/src/lib/utils/efp/index.ts new file mode 100644 index 000000000..5ba0931a7 --- /dev/null +++ b/src/lib/utils/efp/index.ts @@ -0,0 +1,141 @@ +export const EFP_API_BASE = 'https://api.ethfollow.xyz/api/v1'; + +export interface EfpStats { + followers: number; + following: number; +} + +export interface EfpCommonFollower { + address: string; + name?: string; + avatar?: string; + mutualsRank?: number; +} + +export interface EfpFollowerState { + follow: boolean; + block: boolean; + mute: boolean; +} + +function parseCount(value: string | number | undefined): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +function encodeUserId(id: string): string { + return encodeURIComponent(id); +} + +export async function getEfpStats( + addressOrEns: string, + fetchFn: typeof fetch = fetch, +): Promise { + try { + const response = await fetchFn(`${EFP_API_BASE}/users/${encodeUserId(addressOrEns)}/stats`); + if (!response.ok) return null; + + const data = (await response.json()) as { + followers_count?: string | number; + following_count?: string | number; + }; + + const followers = parseCount(data.followers_count); + const following = parseCount(data.following_count); + + return { followers, following }; + } catch { + return null; + } +} + +export async function getCommonFollowers( + addressOrEns: string, + leader: string, + fetchFn: typeof fetch = fetch, + limit = 5, +): Promise { + try { + const url = new URL( + `${EFP_API_BASE}/users/${encodeUserId(addressOrEns)}/commonFollowers`, + ); + url.searchParams.set('leader', leader); + + const response = await fetchFn(url.toString()); + if (!response.ok) return null; + + const data = (await response.json()) as { + results?: Array<{ + address: string; + name?: string; + avatar?: string; + mutuals_rank?: string; + }>; + }; + + if (!data.results?.length) return []; + + return data.results.slice(0, limit).map((row) => ({ + address: row.address, + name: row.name, + avatar: row.avatar, + mutualsRank: row.mutuals_rank ? Number(row.mutuals_rank) : undefined, + })); + } catch { + return null; + } +} + +export async function getFollowerState( + userAddressOrEns: string, + followerAddressOrEns: string, + fetchFn: typeof fetch = fetch, +): Promise { + try { + const response = await fetchFn( + `${EFP_API_BASE}/users/${encodeUserId(userAddressOrEns)}/${encodeUserId(followerAddressOrEns)}/followerState`, + ); + if (!response.ok) return null; + + const data = (await response.json()) as { + state?: { follow?: boolean; block?: boolean; mute?: boolean }; + }; + + if (!data.state) return null; + + return { + follow: !!data.state.follow, + block: !!data.state.block, + mute: !!data.state.mute, + }; + } catch { + return null; + } +} + +export async function getSupportersYouFollow( + viewerAddress: string, + supporterAddresses: string[], + fetchFn: typeof fetch = fetch, + concurrency = 5, +): Promise> { + const followed = new Set(); + const unique = [...new Set(supporterAddresses.map((a) => a.toLowerCase()))].filter( + (a) => a !== viewerAddress.toLowerCase(), + ); + + for (let i = 0; i < unique.length; i += concurrency) { + const batch = unique.slice(i, i + concurrency); + const results = await Promise.all( + batch.map(async (supporter) => { + const state = await getFollowerState(supporter, viewerAddress, fetchFn); + return state?.follow ? supporter : null; + }), + ); + for (const address of results) { + if (address) followed.add(address); + } + } + + return followed; +} diff --git a/src/lib/utils/efp/index.unit.test.ts b/src/lib/utils/efp/index.unit.test.ts new file mode 100644 index 000000000..facc96c6a --- /dev/null +++ b/src/lib/utils/efp/index.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getEfpStats, getFollowerState, getCommonFollowers } from './index'; + +describe('efp api client', () => { + it('parses stats counts from string fields', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ followers_count: '104', following_count: '26' }), + }); + + const stats = await getEfpStats('0xabc', fetchFn); + + expect(stats).toEqual({ followers: 104, following: 26 }); + }); + + it('returns null when stats request fails', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false }); + + expect(await getEfpStats('0xabc', fetchFn)).toBeNull(); + }); + + it('parses follower state', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + state: { follow: true, block: false, mute: false }, + }), + }); + + const state = await getFollowerState('0xuser', '0xfollower', fetchFn); + + expect(state).toEqual({ follow: true, block: false, mute: false }); + }); + + it('parses common followers', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + address: '0x1', + name: 'alice.eth', + mutuals_rank: '3', + }, + ], + }), + }); + + const results = await getCommonFollowers('0xtarget', '0xleader', fetchFn); + + expect(results).toEqual([ + { address: '0x1', name: 'alice.eth', avatar: undefined, mutualsRank: 3 }, + ]); + }); +}); From aa592e950ec6e3156ed7d543c42cecb8a85da995 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 2 Jun 2026 11:42:12 +0200 Subject: [PATCH 02/23] feat(network): add EFP feature flag --- src/lib/stores/wallet/network.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/lib/stores/wallet/network.ts b/src/lib/stores/wallet/network.ts index 0812581b7..59f06f828 100644 --- a/src/lib/stores/wallet/network.ts +++ b/src/lib/stores/wallet/network.ts @@ -80,6 +80,7 @@ export type Network = { } | undefined; enableEns: boolean; + enableEfp: boolean; ecosystems: boolean; retroFunding: DripsRetroFundingConfig; orcids: boolean; @@ -168,6 +169,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { gaslessTransactions: false, addToWalletConfig: undefined, enableEns: true, + enableEfp: true, ecosystems: false, retroFunding: { enabled: false }, orcids: false, @@ -214,6 +216,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { gaslessTransactions: false, addToWalletConfig: undefined, enableEns: true, + enableEfp: true, ecosystems: false, retroFunding: { enabled: false }, orcids: false, @@ -260,6 +263,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { gaslessTransactions: false, addToWalletConfig: undefined, enableEns: true, + enableEfp: true, ecosystems: true, retroFunding: { enabled: false }, orcids: false, @@ -307,6 +311,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { gaslessTransactions: false, addToWalletConfig: undefined, enableEns: true, + enableEfp: true, ecosystems: true, retroFunding: { enabled: true, @@ -373,6 +378,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { rpcUrls: [`${BASE_URL}/api/rpc/anvil/localtestnet`], }, enableEns: false, + enableEfp: true, ecosystems: true, retroFunding: { enabled: true, @@ -424,6 +430,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { gaslessTransactions: false, addToWalletConfig: undefined, enableEns: true, + enableEfp: true, retroFunding: { enabled: false }, ecosystems: false, orcids: false, @@ -489,6 +496,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { ], }, enableEns: true, + enableEfp: true, retroFunding: { enabled: true, attestationConfig: { @@ -554,6 +562,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { rpcUrls: ['https://andromeda.metis.io/?owner=1088'], }, enableEns: true, + enableEfp: true, retroFunding: { enabled: false }, ecosystems: false, orcids: false, @@ -608,6 +617,7 @@ export const NETWORK_CONFIG: ValueForEachSupportedChain = { rpcUrls: ['https://mainnet.optimism.io'], }, enableEns: true, + enableEfp: true, retroFunding: { enabled: false }, ecosystems: false, orcids: false, From fa230128e507fdba174cf5c13b871e0128403d57 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 2 Jun 2026 11:42:12 +0200 Subject: [PATCH 03/23] feat(efp): implement EFP Svelte store for stats and common followers --- src/lib/stores/efp/__mocks__/efp.store.ts | 67 +++++++++++++ src/lib/stores/efp/__mocks__/efp.ts | 28 ++++++ src/lib/stores/efp/efp.store.ts | 111 ++++++++++++++++++++++ src/lib/stores/efp/efp.store.unit.test.ts | 83 ++++++++++++++++ src/lib/stores/efp/index.ts | 1 + 5 files changed, 290 insertions(+) create mode 100644 src/lib/stores/efp/__mocks__/efp.store.ts create mode 100644 src/lib/stores/efp/__mocks__/efp.ts create mode 100644 src/lib/stores/efp/efp.store.ts create mode 100644 src/lib/stores/efp/efp.store.unit.test.ts create mode 100644 src/lib/stores/efp/index.ts diff --git a/src/lib/stores/efp/__mocks__/efp.store.ts b/src/lib/stores/efp/__mocks__/efp.store.ts new file mode 100644 index 000000000..a09f454f4 --- /dev/null +++ b/src/lib/stores/efp/__mocks__/efp.store.ts @@ -0,0 +1,67 @@ +import { writable } from 'svelte/store'; +import type { EfpCommonFollower, EfpStats } from '$lib/utils/efp'; + +export const TEST_ADDRESS = '0x1234567890123456789012345678901234567890'; +export const TEST_VIEWER = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; + +const mockStats: EfpStats = { + followers: 42, + following: 7, +}; + +const mockCommonFollowers: EfpCommonFollower[] = [ + { + address: '0x2222222222222222222222222222222222222222', + name: 'alice.eth', + }, +]; + +function commonFollowersKey(target: string, leader: string) { + return `${target.toLowerCase()}:${leader.toLowerCase()}`; +} + +export default (() => { + const statsState = writable>({}); + const commonFollowersState = writable>({}); + + async function lookupStats(address: string) { + if (address.toLowerCase() === TEST_ADDRESS) { + statsState.set({ [TEST_ADDRESS]: { stats: mockStats } }); + return mockStats; + } + return undefined; + } + + async function lookupCommonFollowers(viewedAddress: string, leaderAddress: string) { + if ( + viewedAddress.toLowerCase() === TEST_ADDRESS && + leaderAddress.toLowerCase() === TEST_VIEWER + ) { + const key = commonFollowersKey(viewedAddress, leaderAddress); + commonFollowersState.set({ [key]: mockCommonFollowers }); + return mockCommonFollowers; + } + return undefined; + } + + return { + subscribe: statsState.subscribe, + subscribeCommonFollowers: commonFollowersState.subscribe, + lookupStats, + lookupCommonFollowers, + getStats: (address: string) => + address.toLowerCase() === TEST_ADDRESS ? mockStats : undefined, + getCommonFollowersFor: (viewed: string, leader: string) => { + const key = commonFollowersKey(viewed, leader); + let value: EfpCommonFollower[] | undefined; + commonFollowersState.subscribe((s) => { + value = s[key]; + })(); + return value; + }, + clear: () => { + statsState.set({}); + commonFollowersState.set({}); + }, + }; +})(); diff --git a/src/lib/stores/efp/__mocks__/efp.ts b/src/lib/stores/efp/__mocks__/efp.ts new file mode 100644 index 000000000..5679ff5e8 --- /dev/null +++ b/src/lib/stores/efp/__mocks__/efp.ts @@ -0,0 +1,28 @@ +import { vi } from 'vitest'; +import type { EfpCommonFollower, EfpStats } from '$lib/utils/efp'; + +const stats: Record = { + '0x1234567890123456789012345678901234567890': { followers: 42, following: 7 }, +}; + +const commonFollowers: Record = {}; + +const defaultExport = { + subscribe: vi.fn((run: (value: Record) => void) => { + run( + Object.fromEntries( + Object.entries(stats).map(([address, s]) => [address, { stats: s }]), + ), + ); + return () => undefined; + }), + subscribeCommonFollowers: vi.fn((run: (value: Record) => void) => { + run(commonFollowers); + return () => undefined; + }), + lookupStats: vi.fn(async (address: string) => stats[address.toLowerCase()]), + lookupCommonFollowers: vi.fn(async () => []), + clear: vi.fn(), +}; + +export default defaultExport; diff --git a/src/lib/stores/efp/efp.store.ts b/src/lib/stores/efp/efp.store.ts new file mode 100644 index 000000000..d1993722f --- /dev/null +++ b/src/lib/stores/efp/efp.store.ts @@ -0,0 +1,111 @@ +import { get, writable } from 'svelte/store'; +import { browser } from '$app/environment'; +import { + getCommonFollowers, + getEfpStats, + type EfpCommonFollower, + type EfpStats, +} from '$lib/utils/efp'; +import network from '../wallet/network'; + +export type EfpAddressRecord = { + stats?: EfpStats; +}; + +type StatsState = { + [address: string]: EfpAddressRecord | undefined; +}; + +type CommonFollowersState = { + [key: string]: EfpCommonFollower[] | undefined; +}; + +function commonFollowersKey(target: string, leader: string) { + return `${target.toLowerCase()}:${leader.toLowerCase()}`; +} + +export default (() => { + const statsState = writable({}); + const commonFollowersState = writable({}); + const inFlightStats = new Set(); + const inFlightCommon = new Set(); + + async function lookupStats(address: string): Promise { + if (!browser || !network.enableEfp) return undefined; + + const key = address.toLowerCase(); + const saved = get(statsState)[key]; + if (saved?.stats) return saved.stats; + if (inFlightStats.has(key)) return undefined; + + inFlightStats.add(key); + statsState.update((s) => ({ ...s, [key]: {} })); + + try { + const stats = await getEfpStats(address); + if (stats) { + statsState.update((s) => ({ ...s, [key]: { stats } })); + return stats; + } + } finally { + inFlightStats.delete(key); + } + + return undefined; + } + + async function lookupCommonFollowers( + targetAddress: string, + leaderAddress: string, + limit = 5, + ): Promise { + if (!browser || !network.enableEfp) return undefined; + + const key = commonFollowersKey(targetAddress, leaderAddress); + const saved = get(commonFollowersState)[key]; + if (saved) return saved; + if (inFlightCommon.has(key)) return undefined; + + inFlightCommon.add(key); + + try { + const results = await getCommonFollowers(targetAddress, leaderAddress, fetch, limit); + if (results) { + commonFollowersState.update((s) => ({ ...s, [key]: results })); + return results; + } + } finally { + inFlightCommon.delete(key); + } + + return undefined; + } + + function getStats(address: string): EfpStats | undefined { + return get(statsState)[address.toLowerCase()]?.stats; + } + + function getCommonFollowersFor( + targetAddress: string, + leaderAddress: string, + ): EfpCommonFollower[] | undefined { + return get(commonFollowersState)[commonFollowersKey(targetAddress, leaderAddress)]; + } + + function clear() { + statsState.set({}); + commonFollowersState.set({}); + inFlightStats.clear(); + inFlightCommon.clear(); + } + + return { + subscribe: statsState.subscribe, + subscribeCommonFollowers: commonFollowersState.subscribe, + lookupStats, + lookupCommonFollowers, + getStats, + getCommonFollowersFor, + clear, + }; +})(); diff --git a/src/lib/stores/efp/efp.store.unit.test.ts b/src/lib/stores/efp/efp.store.unit.test.ts new file mode 100644 index 000000000..65fb9fa06 --- /dev/null +++ b/src/lib/stores/efp/efp.store.unit.test.ts @@ -0,0 +1,83 @@ +import { get } from 'svelte/store'; +import efpStore from '.'; +import * as efpApi from '$lib/utils/efp'; + +vi.mock('$app/environment', () => ({ + browser: true, + dev: true, +})); + +vi.mock('$lib/stores/wallet/network', () => ({ + default: { enableEfp: true }, +})); + +vi.mock('$lib/utils/efp', () => ({ + getEfpStats: vi.fn(), + getCommonFollowers: vi.fn(), +})); + +const getEfpStats = vi.mocked(efpApi.getEfpStats); +const getCommonFollowers = vi.mocked(efpApi.getCommonFollowers); + +afterEach(() => { + efpStore.clear(); + vi.clearAllMocks(); +}); + +describe('efp store', () => { + it('normalizes address keys to lowercase', async () => { + getEfpStats.mockResolvedValue({ followers: 10, following: 2 }); + + await efpStore.lookupStats('0xAbCdEf0123456789012345678901234567890AbCdEf'); + + expect(get(efpStore)['0xabcdef0123456789012345678901234567890abcdef']?.stats).toEqual({ + followers: 10, + following: 2, + }); + }); + + it('deduplicates in-flight stats lookups', async () => { + getEfpStats.mockResolvedValue({ followers: 1, following: 1 }); + + const address = '0x1111111111111111111111111111111111111111'; + void efpStore.lookupStats(address); + void efpStore.lookupStats(address); + await efpStore.lookupStats(address); + + expect(getEfpStats).toHaveBeenCalledTimes(1); + }); + + it('returns undefined without throwing when lookup fails', async () => { + getEfpStats.mockResolvedValue(null); + + const result = await efpStore.lookupStats('0x2222222222222222222222222222222222222222'); + + expect(result).toBeUndefined(); + expect(get(efpStore)['0x2222222222222222222222222222222222222222']).toEqual({}); + }); + + it('clears cached state', async () => { + getEfpStats.mockResolvedValue({ followers: 5, following: 1 }); + await efpStore.lookupStats('0x3333333333333333333333333333333333333333'); + + efpStore.clear(); + + expect(get(efpStore)).toEqual({}); + }); + + it('caches common followers by target and leader', async () => { + getCommonFollowers.mockResolvedValue([ + { address: '0x4444444444444444444444444444444444444444' }, + ]); + + const target = '0x5555555555555555555555555555555555555555'; + const leader = '0x6666666666666666666666666666666666666666'; + + await efpStore.lookupCommonFollowers(target, leader); + + expect(getCommonFollowers).toHaveBeenCalledWith(target, leader, expect.any(Function), 5); + expect(efpStore.getCommonFollowersFor(target, leader)).toEqual([ + { address: '0x4444444444444444444444444444444444444444' }, + ]); + }); +}); diff --git a/src/lib/stores/efp/index.ts b/src/lib/stores/efp/index.ts new file mode 100644 index 000000000..a1d53c71a --- /dev/null +++ b/src/lib/stores/efp/index.ts @@ -0,0 +1 @@ +export { default } from './efp.store'; From a110501651688ca65fab925ba03293bad7c307b3 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 2 Jun 2026 11:42:12 +0200 Subject: [PATCH 04/23] feat(efp): create `EfpStats` Svelte component --- src/lib/components/efp-stats/efp-stats.svelte | 92 +++++++++++++++++++ .../efp-stats/efp-stats.unit.test.ts | 58 ++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/lib/components/efp-stats/efp-stats.svelte create mode 100644 src/lib/components/efp-stats/efp-stats.unit.test.ts diff --git a/src/lib/components/efp-stats/efp-stats.svelte b/src/lib/components/efp-stats/efp-stats.svelte new file mode 100644 index 000000000..469e880e7 --- /dev/null +++ b/src/lib/components/efp-stats/efp-stats.svelte @@ -0,0 +1,92 @@ + + +{#if network.enableEfp && (hasStats || showCommon)} +
+ {#if hasStats && stats} + + {formatNumber(stats.followers)} {stats.followers === 1 ? 'follower' : 'followers'} · {formatNumber(stats.following)} following + + {/if} + {#if showCommon} +
+ Followers you know + +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/efp-stats/efp-stats.unit.test.ts b/src/lib/components/efp-stats/efp-stats.unit.test.ts new file mode 100644 index 000000000..bc310e1b3 --- /dev/null +++ b/src/lib/components/efp-stats/efp-stats.unit.test.ts @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/svelte'; +import EfpStats from './efp-stats.svelte'; + +vi.mock('$lib/stores/wallet/network', () => ({ + default: { enableEfp: true }, +})); + +vi.mock('$lib/components/identity-badge/identity-badge.svelte', () => ({ + default: true, +})); + +describe('efp-stats.svelte', () => { + it('renders follower and following counts when stats are present', () => { + render(EfpStats, { + props: { + address: '0x1234567890123456789012345678901234567890', + stats: { followers: 1200, following: 42 }, + }, + }); + + expect(screen.getByText(/1,200 followers/)).toBeInTheDocument(); + expect(screen.getByText(/42 following/)).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + 'https://efp.app/0x1234567890123456789012345678901234567890', + ); + }); + + it('renders nothing when stats are absent', () => { + const { container } = render(EfpStats, { + props: { + address: '0x1234567890123456789012345678901234567890', + stats: null, + }, + }); + + expect(container.querySelector('.efp-stats')).toBeNull(); + }); + + it('truncates common followers to maxCommonFollowers', () => { + render(EfpStats, { + props: { + address: '0x1234567890123456789012345678901234567890', + stats: { followers: 3, following: 1 }, + showCommonFollowers: true, + maxCommonFollowers: 2, + commonFollowers: [ + { address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + { address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }, + { address: '0xcccccccccccccccccccccccccccccccccccccccc' }, + ], + }, + }); + + expect(screen.getByText('Followers you know')).toBeInTheDocument(); + expect(screen.getByText('+1')).toBeInTheDocument(); + }); +}); From b838a25044af15eb4c268ea0cbddcbf8606ef9bf Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 2 Jun 2026 11:42:12 +0200 Subject: [PATCH 05/23] feat(efp): display EFP stats on `IdentityCard` --- .../components/identity-card/identity-card.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/lib/components/identity-card/identity-card.svelte b/src/lib/components/identity-card/identity-card.svelte index 6bd8ef7a9..922e4152f 100644 --- a/src/lib/components/identity-card/identity-card.svelte +++ b/src/lib/components/identity-card/identity-card.svelte @@ -68,6 +68,9 @@ import EcosystemIcon from '$lib/components/icons/Ecosystem.svelte'; import buildOrcidUrl from '$lib/utils/orcids/build-orcid-url'; import OrcidIcon from '$lib/components/icons/Orcid.svelte'; + import EfpStats from '$lib/components/efp-stats/efp-stats.svelte'; + import efpStore from '$lib/stores/efp'; + import network from '$lib/stores/wallet/network'; // Either pass address, dripList, ecosystem, or project. Otherwise it will say "TBD" as a placeholder. export let address: string | undefined = undefined; @@ -82,6 +85,7 @@ let avatarImgElem: HTMLImageElement | undefined; let link: string | undefined; + $: { switch (true) { case disableLink: @@ -100,6 +104,12 @@ link = buildOrcidUrl(orcid.orcid); } } + + $: if (address && network.enableEfp) { + void efpStore.lookupStats(address); + } + + $: efpStats = address ? $efpStore[address.toLowerCase()]?.stats : undefined; + {#if network.enableEfp} + + {/if} {:else if dripList}
From ac88bf4cefe0c2a28924bc24f857d324cdae945b Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Tue, 2 Jun 2026 11:42:12 +0200 Subject: [PATCH 06/23] feat(efp): add 'Show supporters you follow' feature to `SupportersSection` --- .../supporters.section.svelte | 86 ++++++++++++++++++- .../utils/efp/extract-supporter-addresses.ts | 26 ++++++ 2 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 src/lib/utils/efp/extract-supporter-addresses.ts diff --git a/src/lib/components/supporters-section/supporters.section.svelte b/src/lib/components/supporters-section/supporters.section.svelte index 4bd305bc9..3fc477cb4 100644 --- a/src/lib/components/supporters-section/supporters.section.svelte +++ b/src/lib/components/supporters-section/supporters.section.svelte @@ -122,6 +122,9 @@ import Section from '../section/section.svelte'; import EcosystemBadge from '../ecosystem-badge/ecosystem-badge.svelte'; import unreachable from '$lib/utils/unreachable'; + import network from '$lib/stores/wallet/network'; + import extractSupporterAddresses from '$lib/utils/efp/extract-supporter-addresses'; + import { getSupportersYouFollow } from '$lib/utils/efp'; let emptyStateText: string | undefined = $state(); @@ -138,6 +141,43 @@ sectionSkeleton?: SectionSkeleton | undefined; } + let followedSupporters = $state(new Set()); + let highlightSupportersYouFollow = $state(false); + let loadingFollowedSupporters = $state(false); + + function supporterTag( + address: string, + existingTag: string | undefined, + ): string | undefined { + if (existingTag) return existingTag; + if ( + highlightSupportersYouFollow && + followedSupporters.has(address.toLowerCase()) + ) { + return 'Following'; + } + return undefined; + } + + async function toggleSupportersYouFollow() { + if (highlightSupportersYouFollow) { + highlightSupportersYouFollow = false; + followedSupporters = new Set(); + return; + } + + const viewer = $walletStore.address; + if (!viewer || supporterAddresses.length === 0) return; + + loadingFollowedSupporters = true; + try { + followedSupporters = await getSupportersYouFollow(viewer, supporterAddresses); + highlightSupportersYouFollow = true; + } finally { + loadingFollowedSupporters = false; + } + } + let { supportItems, ownerAccountId = undefined, @@ -149,6 +189,9 @@ infoTooltip = undefined, sectionSkeleton = $bindable(), }: Props = $props(); + + const supporterAddresses = $derived(extractSupporterAddresses(supportItems)); + run(() => { switch (type) { case 'project': @@ -185,6 +228,24 @@ }} bind:skeletonInstance={sectionSkeleton} > + {#if network.enableEfp && type === 'address' && $walletStore.address && supporterAddresses.length > 0} +
+ +
+ {/if}
{#each supportItems as item} {#if item.__typename === 'OneTimeDonationSupport'} @@ -192,12 +253,14 @@ title={{ component: IdentityBadge, props: { - tag: + tag: supporterTag( + item.account.address, item.account.accountId === $walletStore.dripsAccountId ? 'You' : item.account.accountId === ownerAccountId ? 'Owner' : undefined, + ), disableTooltip: true, address: item.account.address, }, @@ -246,12 +309,14 @@ props: { disableLink: true, disableTooltip: true, - tag: + tag: supporterTag( + item.stream.sender.account.address, stream.sender.account.accountId === $walletStore.dripsAccountId ? 'You' : stream.sender.account.accountId === ownerAccountId ? 'Owner' : undefined, + ), address: item.stream.sender.account.address, }, }} @@ -356,6 +421,23 @@