Merge pull request #8 from dergigi/support

Add support page with zap receipt display
This commit is contained in:
Gigi
2025-10-15 01:54:47 +02:00
committed by GitHub
9 changed files with 408 additions and 7 deletions

View File

@@ -37,10 +37,17 @@
## Explore & Profiles
- **Explore**: Discover friends highlights and writings, plus a nostrverse feed.
- **Explore**: Discover friends' highlights and writings, plus a "nostrverse" feed.
- **Filters**: Visibility toggles (mine, friends, nostrverse) apply to Explore highlights.
- **Profiles**: View your own (`/me`) or other users (`/p/:npub`) with tabs for Highlights, Bookmarks, Archive, and Writings.
## Support
- **Supporter page**: Displays avatars of users who zapped Boris (kind:9735 receipts).
- **Thresholds**: Shows supporters who sent ≥ 2100 sats; whales (≥ 69420 sats) get special styling with a bolt badge.
- **Profile integration**: Fetches and displays profile pictures and names for all supporters.
- **Stats**: Total supporter count and zap count displayed at the bottom.
## Video
- **Embedded player**: Plays supported videos (e.g., YouTube) inline with duration display.

1
public/thank-you.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -62,6 +62,15 @@ function AppRoutes({
/>
}
/>
<Route
path="/support"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
}
/>
<Route
path="/explore"
element={

View File

@@ -16,6 +16,7 @@ import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import Me from './Me'
import Support from './Support'
import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large'
@@ -42,6 +43,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const showExplore = location.pathname.startsWith('/explore')
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
@@ -250,6 +252,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
showExplore={showExplore}
showMe={showMe}
showProfile={showProfile}
showSupport={showSupport}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
viewMode={viewMode}
@@ -313,6 +316,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
profile={showProfile && profilePubkey ? (
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : null
) : undefined}
support={showSupport ? (
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined}
toastType={toastType}
onClearToast={clearToast}

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
@@ -117,6 +117,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
ariaLabel="Explore"
variant="ghost"
/>
<IconButton
icon={faBolt}
onClick={() => navigate('/support')}
title="Support"
ariaLabel="Support"
variant="ghost"
/>
<IconButton
icon={faGear}
onClick={onOpenSettings}

235
src/components/Support.tsx Normal file
View File

@@ -0,0 +1,235 @@
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'
import { useNavigate } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
interface SupportProps {
relayPool: RelayPool
eventStore: IEventStore
settings: UserSettings
}
type SupporterProfile = ZapSender
const Support: React.FC<SupportProps> = ({ relayPool, eventStore, settings }) => {
const [supporters, setSupporters] = useState<SupporterProfile[]>([])
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)
}
setSupporters(zappers)
} catch (error) {
console.error('Failed to load supporters:', error)
} finally {
setLoading(false)
}
}
loadSupporters()
}, [relayPool, eventStore, settings])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<FontAwesomeIcon icon={faSpinner} spin size="2x" className="text-zinc-400" />
</div>
)
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text)' }}>
<div className="max-w-5xl mx-auto px-4 py-12 md:py-16">
<div className="text-center mb-16 md:mb-20">
<div className="flex justify-center mb-8">
<img
src="/thank-you.svg"
alt="Thank you"
className="w-56 h-56 md:w-72 md:h-72 opacity-90"
/>
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: 'var(--color-text)' }}>
Thank You!
</h1>
<p className="text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ color: 'var(--color-text-secondary)' }}>
Your{' '}
<a
href="https://www.readwithboris.com/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: 'var(--color-primary)' }}
>
zaps
</a>
{' '}help keep this project alive.
</p>
</div>
{supporters.length === 0 ? (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
<p>No supporters yet. Be the first to zap Boris!</p>
</div>
) : (
<>
{/* Whales Section */}
{supporters.filter(s => s.isWhale).length > 0 && (
<div className="mb-16 md:mb-20">
<h2 className="text-2xl md:text-3xl font-semibold mb-8 md:mb-10 text-center" style={{ color: 'var(--color-text)' }}>
Legends
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-8 md:gap-10">
{supporters.filter(s => s.isWhale).map(supporter => (
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={true} />
))}
</div>
</div>
)}
{/* Regular Supporters Section */}
{supporters.filter(s => !s.isWhale).length > 0 && (
<div className="mb-12">
<h2 className="text-xl md:text-2xl font-semibold mb-8 text-center" style={{ color: 'var(--color-text)' }}>
Supporters
</h2>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 gap-4 md:gap-5">
{supporters.filter(s => !s.isWhale).map(supporter => (
<SupporterCard key={supporter.pubkey} supporter={supporter} isWhale={false} />
))}
</div>
</div>
)}
</>
)}
<div className="mt-16 md:mt-20 pt-8 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
<div className="text-center space-y-4">
<p className="text-base" style={{ color: 'var(--color-text-secondary)' }}>
Zap{' '}
<a
href="https://njump.me/npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: 'var(--color-primary)' }}
>
Boris
</a>
{' '}a{' '}
<a
href="https://www.readwithboris.com/#pricing"
target="_blank"
rel="noopener noreferrer"
className="underline hover:no-underline"
style={{ color: 'var(--color-primary)' }}
>
meaningful amount of sats
</a>
{' '}and your avatar will show above.
</p>
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Total supporters: {supporters.length}
Total zaps: {supporters.reduce((sum, s) => sum + s.zapCount, 0)}
</p>
</div>
</div>
</div>
</div>
)
}
interface SupporterCardProps {
supporter: SupporterProfile
isWhale: boolean
}
const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) => {
const navigate = useNavigate()
const profile = useEventModel(Models.ProfileModel, [supporter.pubkey])
const picture = profile?.picture
const name = profile?.name || profile?.display_name || `${supporter.pubkey.slice(0, 8)}...`
const handleClick = () => {
const npub = nip19.npubEncode(supporter.pubkey)
navigate(`/p/${npub}`)
}
return (
<div className="flex flex-col items-center">
<div className="relative">
{/* Avatar */}
<div
className={`rounded-full overflow-hidden flex items-center justify-center cursor-pointer transition-transform hover:scale-105
${isWhale ? 'w-24 h-24 md:w-28 md:h-28 ring-4 ring-yellow-400' : 'w-10 h-10 md:w-12 md:h-12'}
`}
style={{
backgroundColor: 'var(--color-bg-elevated)'
}}
title={`${name}${supporter.totalSats.toLocaleString()} sats`}
onClick={handleClick}
>
{picture ? (
<img
src={picture}
alt={name}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<FontAwesomeIcon
icon={faUserCircle}
className={isWhale ? 'text-5xl' : 'text-3xl'}
style={{ color: 'var(--color-border)' }}
/>
)}
</div>
{/* Whale Badge */}
{isWhale && (
<div
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
style={{ borderColor: 'var(--color-bg)' }}
>
<FontAwesomeIcon icon={faBolt} className="text-zinc-900 text-sm" />
</div>
)}
</div>
{/* Name and Total */}
<div className="mt-2 text-center">
<p
className={`font-medium truncate max-w-full ${isWhale ? 'text-sm' : 'text-xs'}`}
style={{ color: 'var(--color-text)' }}
>
{name}
</p>
<p
className={isWhale ? 'text-xs' : 'text-[10px]'}
style={{ color: 'var(--color-text-muted)' }}
>
{supporter.totalSats.toLocaleString()} sats
</p>
</div>
</div>
)
}
export default Support

View File

@@ -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<ThreePaneLayoutProps> = (props) => {
@@ -225,8 +229,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (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 && (
<button
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -245,8 +249,8 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
</button>
)}
{/* 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 && (
<button
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
@@ -329,6 +333,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
<>
{props.profile}
</>
) : props.showSupport && props.support ? (
// Render Support inside the main pane to keep side panels
<>
{props.support}
</>
) : (
<ContentPanel
loading={props.readerLoading}

View File

@@ -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,

View File

@@ -0,0 +1,127 @@
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
import { NostrEvent } from 'nostr-tools'
import { isValidZap, getZapSender, getZapAmount } from 'applesauce-core/helpers'
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
import { BORIS_PUBKEY } from './highlightCreationService'
import { RELAYS } from '../config/relays'
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<ZapSender[]> {
try {
console.log('⚡ Fetching zap receipts for Boris...', BORIS_PUBKEY)
// Use all configured relays plus specific zap-heavy relays
const zapRelays = [
...RELAYS,
'wss://nostr.mutinywallet.com', // Common zap relay
'wss://relay.getalby.com/v1', // Alby zap relay
]
const prioritized = prioritizeLocalRelays(zapRelays)
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<NostrEvent>((sub) => sub.complete())
const remote$ = remoteRelays.length > 0
? relayPool
.req(remoteRelays, filter)
.pipe(
onlyEvents(),
completeOnEose(),
takeUntil(timer(6000))
)
: new Observable<NostrEvent>((sub) => sub.complete())
const zapReceipts = await lastValueFrom(
merge(local$, remote$).pipe(toArray())
)
console.log(`📊 Fetched ${zapReceipts.length} raw zap receipts`)
// Dedupe by event ID and validate
const uniqueReceipts = new Map<string, NostrEvent>()
let invalidCount = 0
zapReceipts.forEach(receipt => {
if (!uniqueReceipts.has(receipt.id)) {
if (isValidZap(receipt)) {
uniqueReceipts.set(receipt.id, receipt)
} else {
invalidCount++
}
}
})
console.log(`${uniqueReceipts.size} valid zap receipts (${invalidCount} invalid)`)
// Aggregate by sender using applesauce helpers
const senderTotals = new Map<string, { totalSats: number; zapCount: number }>()
for (const receipt of uniqueReceipts.values()) {
const senderPubkey = getZapSender(receipt)
const amountMsats = getZapAmount(receipt)
if (!senderPubkey || !amountMsats || amountMsats === 0) {
console.warn('Invalid zap receipt - missing sender or amount:', receipt.id)
continue
}
const amountSats = Math.floor(amountMsats / 1000)
const existing = senderTotals.get(senderPubkey) || { totalSats: 0, zapCount: 0 }
senderTotals.set(senderPubkey, {
totalSats: existing.totalSats + amountSats,
zapCount: existing.zapCount + 1
})
}
console.log(`👥 Found ${senderTotals.size} unique senders`)
// 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 []
}
}