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..6c633f94b --- /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..a94ecc7f7 --- /dev/null +++ b/src/lib/components/efp-stats/efp-stats.unit.test.ts @@ -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(); + }); +}); diff --git a/src/lib/components/efp-stats/identity-badge-stub.svelte b/src/lib/components/efp-stats/identity-badge-stub.svelte new file mode 100644 index 000000000..11fa49a59 --- /dev/null +++ b/src/lib/components/efp-stats/identity-badge-stub.svelte @@ -0,0 +1,9 @@ + + +{address} diff --git a/src/lib/components/efp-stats/pile-stub.svelte b/src/lib/components/efp-stats/pile-stub.svelte new file mode 100644 index 000000000..c60c480e4 --- /dev/null +++ b/src/lib/components/efp-stats/pile-stub.svelte @@ -0,0 +1,13 @@ + + +{#if overflowAmount > 0}+{overflowAmount}{/if} diff --git a/src/lib/components/identity-card/identity-card.svelte b/src/lib/components/identity-card/identity-card.svelte index 6bd8ef7a9..e7721576c 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; + {:else if dripList}
diff --git a/src/lib/components/supporters-section/supporters.section.svelte b/src/lib/components/supporters-section/supporters.section.svelte index 4bd305bc9..b93e4c92e 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,25 @@ }} bind:skeletonInstance={sectionSkeleton} > + {#if network.enableEfp && type === 'address' && $walletStore.address && supporterAddresses.length > 0} +
+ +
+ {/if}
{#each supportItems as item} {#if item.__typename === 'OneTimeDonationSupport'} @@ -192,12 +254,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 +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, }, }} @@ -356,6 +422,23 @@