From f18315be024a22bc00c7db5b0e24c0245c8045ab Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 00:53:09 +0200 Subject: [PATCH 01/30] feat: export BORIS_PUBKEY for reuse in support page --- src/services/highlightCreationService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/highlightCreationService.ts b/src/services/highlightCreationService.ts index f757b0a8..edbdd47d 100644 --- a/src/services/highlightCreationService.ts +++ b/src/services/highlightCreationService.ts @@ -12,7 +12,7 @@ import { markEventAsOfflineCreated } from './offlineSyncService' // Boris pubkey for zap splits // npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x -const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e' +export const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e' const { getHighlightText, From 36897e7f15d1c197bb4806c3f476779daa4c79d6 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 00:53:14 +0200 Subject: [PATCH 02/30] feat: add zapReceiptService to fetch and aggregate kind:9735 receipts --- src/services/zapReceiptService.ts | 151 ++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/services/zapReceiptService.ts diff --git a/src/services/zapReceiptService.ts b/src/services/zapReceiptService.ts new file mode 100644 index 00000000..e1a5cba6 --- /dev/null +++ b/src/services/zapReceiptService.ts @@ -0,0 +1,151 @@ +import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' +import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs' +import { NostrEvent } from 'nostr-tools' +import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers' +import { BORIS_PUBKEY } from './highlightCreationService' + +export interface ZapSender { + pubkey: string + totalSats: number + zapCount: number + isWhale: boolean // >= 69420 sats +} + +/** + * Fetches zap receipts (kind:9735) for Boris and aggregates by sender + * @param relayPool - The relay pool to query + * @returns Array of senders who zapped >= 2100 sats, sorted by total desc + */ +export async function fetchBorisZappers( + relayPool: RelayPool +): Promise { + try { + console.log('⚡ Fetching zap receipts for Boris...') + + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const prioritized = prioritizeLocalRelays(relayUrls) + const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized) + + // Fetch zap receipts with Boris as recipient + const filter = { + kinds: [9735], + '#p': [BORIS_PUBKEY] + } + + const local$ = localRelays.length > 0 + ? relayPool + .req(localRelays, filter) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(1200)) + ) + : new Observable((sub) => sub.complete()) + + const remote$ = remoteRelays.length > 0 + ? relayPool + .req(remoteRelays, filter) + .pipe( + onlyEvents(), + completeOnEose(), + takeUntil(timer(6000)) + ) + : new Observable((sub) => sub.complete()) + + const zapReceipts = await lastValueFrom( + merge(local$, remote$).pipe(toArray()) + ) + + console.log(`📊 Fetched ${zapReceipts.length} zap receipts`) + + // Dedupe by event ID + const uniqueReceipts = new Map() + zapReceipts.forEach(receipt => { + if (!uniqueReceipts.has(receipt.id)) { + uniqueReceipts.set(receipt.id, receipt) + } + }) + + // Aggregate by sender + const senderTotals = new Map() + + for (const receipt of uniqueReceipts.values()) { + const senderPubkey = extractSenderPubkey(receipt) + const amountSats = extractAmountSats(receipt) + + if (!senderPubkey || amountSats === null) { + continue + } + + const existing = senderTotals.get(senderPubkey) || { totalSats: 0, zapCount: 0 } + senderTotals.set(senderPubkey, { + totalSats: existing.totalSats + amountSats, + zapCount: existing.zapCount + 1 + }) + } + + // Filter >= 2100 sats, mark whales >= 69420 sats, sort by total desc + const zappers: ZapSender[] = Array.from(senderTotals.entries()) + .filter(([_, data]) => data.totalSats >= 2100) + .map(([pubkey, data]) => ({ + pubkey, + totalSats: data.totalSats, + zapCount: data.zapCount, + isWhale: data.totalSats >= 69420 + })) + .sort((a, b) => b.totalSats - a.totalSats) + + console.log(`✅ Found ${zappers.length} supporters (${zappers.filter(z => z.isWhale).length} whales)`) + + return zappers + } catch (error) { + console.error('Failed to fetch zap receipts:', error) + return [] + } +} + +/** + * Extract sender pubkey from zap receipt + * Try description.pubkey first, fallback to P tag + */ +function extractSenderPubkey(receipt: NostrEvent): string | null { + // Try description tag (JSON-encoded zap request) + const descTag = receipt.tags.find(t => t[0] === 'description') + if (descTag && descTag[1]) { + try { + const zapRequest = JSON.parse(descTag[1]) + if (zapRequest.pubkey) { + return zapRequest.pubkey + } + } catch { + // Invalid JSON, continue + } + } + + // Fallback to P tag (sender from zap request) + const pTag = receipt.tags.find(t => t[0] === 'P') + if (pTag && pTag[1]) { + return pTag[1] + } + + return null +} + +/** + * Extract amount in sats from zap receipt + * Use amount tag (millisats), skip if missing + */ +function extractAmountSats(receipt: NostrEvent): number | null { + const amountTag = receipt.tags.find(t => t[0] === 'amount') + if (!amountTag || !amountTag[1]) { + return null + } + + const millisats = parseInt(amountTag[1], 10) + if (isNaN(millisats) || millisats <= 0) { + return null + } + + return Math.floor(millisats / 1000) +} + From 47ddf8ebe11160e419dd6ab9ba947ad3e8803bf4 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 00:53:18 +0200 Subject: [PATCH 03/30] feat: add Support component to display zappers with avatar grid --- src/components/Support.tsx | 184 +++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/components/Support.tsx diff --git a/src/components/Support.tsx b/src/components/Support.tsx new file mode 100644 index 00000000..0bd86b9e --- /dev/null +++ b/src/components/Support.tsx @@ -0,0 +1,184 @@ +import React, { useEffect, useState } from 'react' +import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons' +import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService' +import { fetchProfiles } from '../services/profileService' +import { UserSettings } from '../services/settingsService' +import { Models } from 'applesauce-core' +import { useEventModel } from 'applesauce-react/hooks' + +interface SupportProps { + relayPool: RelayPool + eventStore: IEventStore + settings: UserSettings +} + +interface SupporterProfile extends ZapSender { + name?: string + picture?: string +} + +const Support: React.FC = ({ relayPool, eventStore, settings }) => { + const [supporters, setSupporters] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const loadSupporters = async () => { + setLoading(true) + try { + const zappers = await fetchBorisZappers(relayPool) + + if (zappers.length > 0) { + const pubkeys = zappers.map(z => z.pubkey) + await fetchProfiles(relayPool, eventStore, pubkeys, settings) + + // Map zappers with profile data + const withProfiles = zappers.map(zapper => { + const profile = eventStore.getProfile(zapper.pubkey) + return { + ...zapper, + name: profile?.name || profile?.display_name || `${zapper.pubkey.slice(0, 8)}...`, + picture: profile?.picture + } + }) + + setSupporters(withProfiles) + } + } catch (error) { + console.error('Failed to load supporters:', error) + } finally { + setLoading(false) + } + } + + loadSupporters() + }, [relayPool, eventStore, settings]) + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+

+ Support Boris +

+

+ Thank you to everyone who has supported Boris! Your zaps help keep this project alive. +

+
+ + {supporters.length === 0 ? ( +
+

No supporters yet. Be the first to zap Boris!

+
+ ) : ( + <> + {/* Whales Section */} + {supporters.filter(s => s.isWhale).length > 0 && ( +
+

+ + Mega Supporters +

+
+ {supporters.filter(s => s.isWhale).map(supporter => ( + + ))} +
+
+ )} + + {/* Regular Supporters Section */} + {supporters.filter(s => !s.isWhale).length > 0 && ( +
+

+ Supporters +

+
+ {supporters.filter(s => !s.isWhale).map(supporter => ( + + ))} +
+
+ )} + + )} + +
+

+ Total supporters: {supporters.length} • + Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)} +

+
+
+ ) +} + +interface SupporterCardProps { + supporter: SupporterProfile + isWhale: boolean +} + +const SupporterCard: React.FC = ({ supporter, isWhale }) => { + const profile = useEventModel(Models.ProfileModel, [supporter.pubkey]) + + const picture = profile?.picture || supporter.picture + const name = profile?.name || profile?.display_name || supporter.name + + return ( +
+
+ {/* Avatar */} +
+ {picture ? ( + {name} + ) : ( + + )} +
+ + {/* Whale Badge */} + {isWhale && ( +
+ +
+ )} +
+ + {/* Name and Total */} +
+

+ {name} +

+

+ {supporter.totalSats.toLocaleString()} sats +

+
+
+ ) +} + +export default Support + From 6812584b8c8ed62edff4c727c60c119b1f7f23e5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 15 Oct 2025 00:53:22 +0200 Subject: [PATCH 04/30] feat: extend ThreePaneLayout with showSupport and support slot --- src/components/ThreePaneLayout.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index 8c115cec..016f3650 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -32,6 +32,7 @@ interface ThreePaneLayoutProps { showExplore?: boolean showMe?: boolean showProfile?: boolean + showSupport?: boolean // Bookmarks pane bookmarks: Bookmark[] @@ -93,6 +94,9 @@ interface ThreePaneLayoutProps { // Optional Profile content profile?: React.ReactNode + + // Optional Support content + support?: React.ReactNode } const ThreePaneLayout: React.FC = (props) => { @@ -225,8 +229,8 @@ const ThreePaneLayout: React.FC = (props) => { return ( <> - {/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */} - {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && ( + {/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile/support) */} + {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && !props.showSupport && ( )} - {/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */} - {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && ( + {/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile/support) */} + {isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && !props.showSupport && (