Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d9cd9b4
feat(efp): add EFP API client and types
Quantumlyy Jun 2, 2026
aa592e9
feat(network): add EFP feature flag
Quantumlyy Jun 2, 2026
fa23012
feat(efp): implement EFP Svelte store for stats and common followers
Quantumlyy Jun 2, 2026
a110501
feat(efp): create `EfpStats` Svelte component
Quantumlyy Jun 2, 2026
b838a25
feat(efp): display EFP stats on `IdentityCard`
Quantumlyy Jun 2, 2026
ac88bf4
feat(efp): add 'Show supporters you follow' feature to `SupportersSec…
Quantumlyy Jun 2, 2026
2f626d6
feat(efp): display EFP stats and common followers on profile page
Quantumlyy Jun 2, 2026
8aef357
feat(efp): introduce async request guard to prevent race conditions
Quantumlyy Jun 2, 2026
214b22d
build(deps): update zod dependency for web3-onboard packages in lockfile
Quantumlyy Jun 2, 2026
7887855
feat(efp): Add hydrateStats to EFP store for SSR hydration
Quantumlyy Jun 2, 2026
fdc45ff
chore(efp): Export `commonFollowersKey` from EFP store
Quantumlyy Jun 2, 2026
ca3c66f
refactor(efp): Consolidate EFP store mocks
Quantumlyy Jun 2, 2026
d28c0e1
test(efp): Add stub components for `EfpStats` unit tests
Quantumlyy Jun 2, 2026
0ca96da
refactor(efp): Improve `extractSupporterAddresses` utility and add tests
Quantumlyy Jun 2, 2026
f7a52ec
test(efp): Add unit test for `getSupportersYouFollow`
Quantumlyy Jun 2, 2026
a6526e5
feat(pages): Integrate EFP stats and common followers on account page
Quantumlyy Jun 2, 2026
aa502aa
chore(components): Remove redundant EFP check from IdentityCard
Quantumlyy Jun 2, 2026
efbc591
refactor: Remove unused async request guard utility
Quantumlyy Jun 2, 2026
37c76a3
refactor(account-page): Remove async request guard usage
Quantumlyy Jun 2, 2026
26a9da7
feat(efp): Improve mutualsRank parsing and add test
Quantumlyy Jun 2, 2026
fae5783
test(efp): Export commonFollowersKey from mock store
Quantumlyy Jun 2, 2026
718903b
fix(efp-stats): URL encode address in profile link
Quantumlyy Jun 2, 2026
4b2c2b5
feat(supporters): Add aria-pressed to filter toggle button
Quantumlyy Jun 2, 2026
2b6b679
Merge branch 'main' into efp
Quantumlyy Jun 5, 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
92 changes: 92 additions & 0 deletions src/lib/components/efp-stats/efp-stats.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<script lang="ts">
import Pile from '$lib/components/pile/pile.svelte';
import IdentityBadge from '$lib/components/identity-badge/identity-badge.svelte';
import formatNumber from '$lib/utils/format-number';
import type { EfpCommonFollower, EfpStats } from '$lib/utils/efp';
import network from '$lib/stores/wallet/network';

interface Props {
address: string;
stats?: EfpStats | null;
commonFollowers?: EfpCommonFollower[];
showCommonFollowers?: boolean;
maxCommonFollowers?: number;
}

let {
address,
stats = null,
commonFollowers = [],
showCommonFollowers = false,
maxCommonFollowers = 5,
}: Props = $props();

const profileUrl = $derived(`https://efp.app/${encodeURIComponent(address)}`);

const hasStats = $derived(
!!stats && (stats.followers > 0 || stats.following > 0),
);

const visibleCommonFollowers = $derived(
showCommonFollowers ? commonFollowers.slice(0, maxCommonFollowers) : [],
);

const showCommon = $derived(visibleCommonFollowers.length > 0);

const pileComponents = $derived(
visibleCommonFollowers.map((follower) => ({
component: IdentityBadge,
props: {
address: follower.address,
disableLink: false,
disableTooltip: true,
size: 'small' as const,
},
})),
);
</script>

{#if network.enableEfp && (hasStats || showCommon)}
<div class="efp-stats typo-text-small">
{#if hasStats && stats}
<a class="counts" href={profileUrl} target="_blank" rel="noopener noreferrer">
{formatNumber(stats.followers)} {stats.followers === 1 ? 'follower' : 'followers'} · {formatNumber(stats.following)} following
</a>
{/if}
{#if showCommon}
<div class="common-followers">
<span class="label typo-all-caps">Followers you know</span>
<Pile components={pileComponents} maxItems={maxCommonFollowers} countOverride={commonFollowers.length} />
</div>
{/if}
</div>
{/if}

<style>
.efp-stats {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: var(--color-foreground-level-5);
}

.counts {
color: var(--color-foreground-level-5);
text-decoration: none;
}

.counts:hover {
color: var(--color-primary);
text-decoration: underline;
}

.common-followers {
display: flex;
flex-direction: column;
gap: 0.375rem;
}

.label {
color: var(--color-foreground-level-4);
}
</style>
62 changes: 62 additions & 0 deletions src/lib/components/efp-stats/efp-stats.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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', async () => ({
default: (await import('./identity-badge-stub.svelte')).default,
}));

vi.mock('$lib/components/pile/pile.svelte', async () => ({
default: (await import('./pile-stub.svelte')).default,
}));

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();
});
});
9 changes: 9 additions & 0 deletions src/lib/components/efp-stats/identity-badge-stub.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
interface Props {
address?: string;
}

let { address }: Props = $props();
</script>

<span data-testid="identity-badge-stub">{address}</span>
13 changes: 13 additions & 0 deletions src/lib/components/efp-stats/pile-stub.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
maxItems?: number;
components?: unknown[];
countOverride?: number;
}

let { maxItems = 4, components = [], countOverride }: Props = $props();

const overflowAmount = $derived((countOverride ?? components.length) - maxItems);
</script>

{#if overflowAmount > 0}<span>+{overflowAmount}</span>{/if}
11 changes: 11 additions & 0 deletions src/lib/components/identity-card/identity-card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -82,6 +85,7 @@
let avatarImgElem: HTMLImageElement | undefined;

let link: string | undefined;

$: {
switch (true) {
case disableLink:
Expand All @@ -100,6 +104,12 @@
link = buildOrcidUrl(orcid.orcid);
}
}

$: if (address && network.enableEfp) {
void efpStore.lookupStats(address);
}

$: efpStats = address ? $efpStore[address.toLowerCase()]?.stats : undefined;
</script>

<svelte:element
Expand All @@ -119,6 +129,7 @@
disableTooltip
/>
<IdentityBadge disableLink={true} size="huge" {address} showAvatar={false} disableTooltip />
<EfpStats {address} stats={efpStats} />
</div>
{:else if dripList}
<div class="content-container" in:fade>
Expand Down
87 changes: 85 additions & 2 deletions src/lib/components/supporters-section/supporters.section.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -138,6 +141,43 @@
sectionSkeleton?: SectionSkeleton | undefined;
}

let followedSupporters = $state(new Set<string>());
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,
Expand All @@ -149,6 +189,9 @@
infoTooltip = undefined,
sectionSkeleton = $bindable(),
}: Props = $props();

const supporterAddresses = $derived(extractSupporterAddresses(supportItems));

run(() => {
switch (type) {
case 'project':
Expand Down Expand Up @@ -185,19 +228,40 @@
}}
bind:skeletonInstance={sectionSkeleton}
>
{#if network.enableEfp && type === 'address' && $walletStore.address && supporterAddresses.length > 0}
<div class="supporters-you-follow">
<button
type="button"
class="typo-text-small"
disabled={loadingFollowedSupporters}
aria-pressed={highlightSupportersYouFollow}
onclick={toggleSupportersYouFollow}
>
Comment thread
Quantumlyy marked this conversation as resolved.
{#if loadingFollowedSupporters}
Loading…
{:else if highlightSupportersYouFollow}
Hide supporters you follow
{:else}
Show supporters you follow
{/if}
</button>
</div>
{/if}
<div class="items">
{#each supportItems as item}
{#if item.__typename === 'OneTimeDonationSupport'}
<SupportItem
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,
},
Expand Down Expand Up @@ -246,12 +310,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,
},
}}
Expand Down Expand Up @@ -356,6 +422,23 @@
</section>

<style>
.supporters-you-follow {
margin-bottom: 0.75rem;
}

.supporters-you-follow button {
color: var(--color-primary);
background: none;
border: none;
padding: 0;
cursor: pointer;
}

.supporters-you-follow button:disabled {
opacity: 0.6;
cursor: default;
}

.items {
border: 1px solid var(--color-foreground-level-3);
border-radius: 1rem 0 1rem 1rem;
Expand Down
Loading