diff --git a/src/components/GrimoireWelcome.tsx b/src/components/GrimoireWelcome.tsx index db6097c..6791e47 100644 --- a/src/components/GrimoireWelcome.tsx +++ b/src/components/GrimoireWelcome.tsx @@ -1,6 +1,10 @@ import { Terminal } from "lucide-react"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; +import { Progress } from "./ui/progress"; +import { MONTHLY_GOAL_SATS } from "@/services/supporters"; +import { useLiveQuery } from "dexie-react-hooks"; +import db from "@/services/db"; interface GrimoireWelcomeProps { onLaunchCommand: () => void; @@ -8,23 +12,53 @@ interface GrimoireWelcomeProps { } const EXAMPLE_COMMANDS = [ + { + command: "zap grimoire.rocks", + description: "Support Grimoire development", + showProgress: true, + }, { command: "chat groups.0xchat.com'NkeVhXuWHGKKJCpn", description: "Join the Grimoire welcome chat", }, - { command: "nip 29", description: "View relay-based groups spec" }, { command: "profile fiatjaf.com", description: "Explore a Nostr profile", }, { command: "req -k 1 -l 20", description: "Query recent notes" }, - { command: "nips", description: "Browse all NIPs" }, ]; export function GrimoireWelcome({ onLaunchCommand, onExecuteCommand, }: GrimoireWelcomeProps) { + // Calculate monthly donations reactively from DB (last 30 days) + const monthlyDonations = + useLiveQuery(async () => { + const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; + let total = 0; + await db.grimoireZaps + .where("timestamp") + .aboveOrEqual(thirtyDaysAgo) + .each((zap) => { + total += zap.amountSats; + }); + return total; + }, []) ?? 0; + + // Calculate progress + const goalProgress = (monthlyDonations / MONTHLY_GOAL_SATS) * 100; + + // Format sats + function formatSats(sats: number): string { + if (sats >= 1_000_000) { + return `${(sats / 1_000_000).toFixed(1)}M`; + } else if (sats >= 1_000) { + return `${Math.floor(sats / 1_000)}k`; + } + return sats.toString(); + } + return (
@@ -89,7 +123,7 @@ export function GrimoireWelcome({

Try these commands:

- {EXAMPLE_COMMANDS.map(({ command, description }) => ( + {EXAMPLE_COMMANDS.map(({ command, description, showProgress }) => ( ))}
diff --git a/src/components/nostr/UserName.tsx b/src/components/nostr/UserName.tsx index 4134a2a..f2f8a18 100644 --- a/src/components/nostr/UserName.tsx +++ b/src/components/nostr/UserName.tsx @@ -3,7 +3,8 @@ import { getDisplayName } from "@/lib/nostr-utils"; import { cn } from "@/lib/utils"; import { useGrimoire } from "@/core/state"; import { isGrimoireMember } from "@/lib/grimoire-members"; -import { BadgeCheck } from "lucide-react"; +import { BadgeCheck, Zap } from "lucide-react"; +import { useIsSupporter } from "@/hooks/useIsSupporter"; interface UserNameProps { pubkey: string; @@ -20,11 +21,15 @@ interface UserNameProps { * - Orange→Amber gradient for logged-in member * - Violet→Fuchsia gradient for other members * - BadgeCheck icon that scales with username size + * Shows Grimoire supporters (non-members who zapped): + * - Premium supporters (2.1k+ sats/month): Zap badge in their username color + * - Regular supporters: Yellow zap badge (no username color change) */ export function UserName({ pubkey, isMention, className }: UserNameProps) { const { addWindow, state } = useGrimoire(); const profile = useProfile(pubkey); const isGrimoire = isGrimoireMember(pubkey); + const { isSupporter, isPremiumSupporter } = useIsSupporter(pubkey); const displayName = getDisplayName(pubkey, profile); // Check if this is the logged-in user @@ -66,6 +71,23 @@ export function UserName({ pubkey, isMention, className }: UserNameProps) { )} /> )} + {!isGrimoire && isSupporter && ( + + )} ); } diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 7274dd6..7bd0fec 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -7,6 +7,7 @@ import { RefreshCw, Eye, EyeOff, + Zap, } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; @@ -14,6 +15,8 @@ import { use$ } from "applesauce-react/hooks"; import { getDisplayName } from "@/lib/nostr-utils"; import { useGrimoire } from "@/core/state"; import { Button } from "@/components/ui/button"; +import { useLiveQuery } from "dexie-react-hooks"; +import db from "@/services/db"; import { DropdownMenu, DropdownMenuContent, @@ -43,6 +46,12 @@ import { useState } from "react"; import { useTheme } from "@/lib/themes"; import { toast } from "sonner"; import { useWallet } from "@/hooks/useWallet"; +import { Progress } from "@/components/ui/progress"; +import { + GRIMOIRE_DONATE_PUBKEY, + GRIMOIRE_LIGHTNING_ADDRESS, +} from "@/lib/grimoire-members"; +import { MONTHLY_GOAL_SATS } from "@/services/supporters"; function UserAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); @@ -86,6 +95,33 @@ export default function UserMenu() { const [showWalletInfo, setShowWalletInfo] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); + // Calculate monthly donations reactively from DB (last 30 days) + const monthlyDonations = + useLiveQuery(async () => { + const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; + let total = 0; + await db.grimoireZaps + .where("timestamp") + .aboveOrEqual(thirtyDaysAgo) + .each((zap) => { + total += zap.amountSats; + }); + return total; + }, []) ?? 0; + + // Calculate monthly donation progress + const goalProgress = (monthlyDonations / MONTHLY_GOAL_SATS) * 100; + + // Format numbers for display + function formatSats(sats: number): string { + if (sats >= 1_000_000) { + return `${(sats / 1_000_000).toFixed(1)}M`; + } else if (sats >= 1_000) { + return `${Math.floor(sats / 1_000)}k`; + } + return sats.toString(); + } + // Get wallet service profile for display name, using wallet relays as hints const walletServiceProfile = useProfile( nwcConnection?.service, @@ -113,6 +149,17 @@ export default function UserMenu() { addWindow("wallet", {}, "Wallet"); } + function openDonate() { + addWindow( + "zap", + { + recipientPubkey: GRIMOIRE_DONATE_PUBKEY, + recipientLightningAddress: GRIMOIRE_LIGHTNING_ADDRESS, + }, + "Support Grimoire", + ); + } + async function logout() { if (!account) return; accounts.removeAccount(account); @@ -376,6 +423,28 @@ export default function UserMenu() { )} + {/* Support Grimoire Section */} + + +
+
+ + Support Grimoire +
+
+ Monthly goal + + {formatSats(monthlyDonations)} /{" "} + {formatSats(MONTHLY_GOAL_SATS)} sats + +
+ +
+
+ {account && ( <> {relays && relays.length > 0 && ( diff --git a/src/hooks/useIsSupporter.ts b/src/hooks/useIsSupporter.ts new file mode 100644 index 0000000..63dd011 --- /dev/null +++ b/src/hooks/useIsSupporter.ts @@ -0,0 +1,50 @@ +/** + * Hook to check if a user is a Grimoire supporter + */ + +import { useLiveQuery } from "dexie-react-hooks"; +import { useState, useEffect } from "react"; +import supportersService from "@/services/supporters"; +import db from "@/services/db"; + +/** + * Check if a pubkey belongs to a Grimoire supporter + * @param pubkey - User's hex public key + * @returns Object with supporter status and premium status + */ +export function useIsSupporter(pubkey: string | undefined): { + isSupporter: boolean; + isPremiumSupporter: boolean; +} { + // Get all unique supporter pubkeys reactively from DB + const supporterPubkeys = useLiveQuery( + () => db.grimoireZaps.orderBy("senderPubkey").uniqueKeys(), + [], + ); + + const [isPremium, setIsPremium] = useState(false); + + // Convert to Set for efficient lookup + const supporters = supporterPubkeys + ? new Set(supporterPubkeys as string[]) + : new Set(); + + // Check premium status async + useEffect(() => { + if (!pubkey || !supporters.has(pubkey)) { + setIsPremium(false); + return; + } + + supportersService.isPremiumSupporter(pubkey).then(setIsPremium); + }, [pubkey, supporters.size]); // Use supporters.size to avoid Set equality issues + + if (!pubkey) { + return { isSupporter: false, isPremiumSupporter: false }; + } + + return { + isSupporter: supporters.has(pubkey), + isPremiumSupporter: isPremium, + }; +} diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index b6ad479..4f71468 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -720,7 +720,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { } // Default fallback relays for live activities - return ["wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band"]; + return ["wss://relay.damus.io", "wss://nos.lol", "wss://purplepag.es"]; } /** diff --git a/src/lib/grimoire-members.ts b/src/lib/grimoire-members.ts index 41e7c34..9527888 100644 --- a/src/lib/grimoire-members.ts +++ b/src/lib/grimoire-members.ts @@ -94,3 +94,15 @@ export function getGrimoireUsername(pubkey: string): string | undefined { export function getGrimoireNip05(pubkey: string): string | undefined { return membersByPubkey.get(pubkey.toLowerCase())?.nip05; } + +/** + * Official Grimoire project pubkey for donations + * Corresponds to user "_" in GRIMOIRE_MEMBERS + */ +export const GRIMOIRE_DONATE_PUBKEY = + "c8fb0d3aa788b9ace4f6cb92dd97d3f292db25b5c9f92462ef6c64926129fbaf"; + +/** + * Official Grimoire project Lightning address for donations + */ +export const GRIMOIRE_LIGHTNING_ADDRESS = "grimoire@coinos.io"; diff --git a/src/lib/relay-transformer.test.ts b/src/lib/relay-transformer.test.ts index c99feea..0cae894 100644 --- a/src/lib/relay-transformer.test.ts +++ b/src/lib/relay-transformer.test.ts @@ -296,7 +296,7 @@ describe("relayReferences transformer", () => { "wss://relay.damus.io", "wss://nos.lol", "wss://relay.snort.social", - "wss://relay.nostr.band", + "wss://purplepag.es", "wss://nostr.wine", ]; diff --git a/src/main.tsx b/src/main.tsx index 3959323..59c887b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,10 +9,14 @@ import { TooltipProvider } from "./components/ui/tooltip"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { initializeErrorHandling } from "./lib/error-handler"; import { ThemeProvider } from "./lib/themes"; +import { initSupporters } from "./services/supporters"; // Initialize global error handling initializeErrorHandling(); +// Initialize supporter tracking +initSupporters(); + createRoot(document.getElementById("root")!).render( diff --git a/src/services/db.ts b/src/services/db.ts index 7523d98..4dd8206 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -100,6 +100,14 @@ export interface LnurlCache { fetchedAt: number; // Timestamp for cache invalidation } +export interface GrimoireZap { + eventId: string; // Primary key - zap receipt event ID + senderPubkey: string; // Who sent the zap + amountSats: number; // Amount in sats (not msats) + timestamp: number; // Unix timestamp when zap was sent (created_at) + comment?: string; // Optional zap comment/message +} + class GrimoireDb extends Dexie { profiles!: Table; nip05!: Table; @@ -112,6 +120,7 @@ class GrimoireDb extends Dexie { spells!: Table; spellbooks!: Table; lnurlCache!: Table; + grimoireZaps!: Table; constructor(name: string) { super(name); @@ -362,6 +371,23 @@ class GrimoireDb extends Dexie { spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", lnurlCache: "&address, fetchedAt", }); + + // Version 17: Add Grimoire donation tracking + this.version(17).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + lnurlCache: "&address, fetchedAt", + grimoireZaps: + "&eventId, senderPubkey, timestamp, [senderPubkey+timestamp]", + }); } } diff --git a/src/services/loaders.test.ts b/src/services/loaders.test.ts index fbd5780..4c5ebb6 100644 --- a/src/services/loaders.test.ts +++ b/src/services/loaders.test.ts @@ -76,9 +76,7 @@ describe("eventLoader", () => { expect(result).toBeDefined(); expect((result as any)._testPointer.id).toBe("test123"); // mergeRelaySets normalizes URLs with trailing slash - expect((result as any)._testPointer.relays).toContain( - "wss://relay.nostr.band/", - ); + expect((result as any)._testPointer.relays).toContain("wss://nos.lol/"); }); it("should handle EventPointer with relay hints", () => { @@ -100,9 +98,7 @@ describe("eventLoader", () => { expect(result).toBeDefined(); // mergeRelaySets normalizes URLs with trailing slash - expect((result as any)._testPointer.relays).toContain( - "wss://relay.nostr.band/", - ); + expect((result as any)._testPointer.relays).toContain("wss://nos.lol/"); }); }); @@ -249,7 +245,7 @@ describe("eventLoader", () => { expect(relays).toContain("wss://r-tag.com/"); expect(relays).toContain("wss://e-tag.com/"); // mergeRelaySets normalizes aggregator relays with trailing slash - expect(relays).toContain("wss://relay.nostr.band/"); + expect(relays).toContain("wss://nos.lol/"); }); }); @@ -328,9 +324,7 @@ describe("eventLoader", () => { expect(result).toBeDefined(); // mergeRelaySets normalizes aggregator relays with trailing slash - expect((result as any)._testPointer.relays).toContain( - "wss://relay.nostr.band/", - ); + expect((result as any)._testPointer.relays).toContain("wss://nos.lol/"); }); it("should handle invalid e tags gracefully", () => { @@ -390,7 +384,7 @@ describe("eventLoader", () => { const relays = (result as any)._testPointer.relays; // Should only have aggregator relays (normalized with trailing slash) - expect(relays).toContain("wss://relay.nostr.band/"); + expect(relays).toContain("wss://nos.lol/"); expect(relays).toContain("wss://nos.lol/"); expect(relays).toContain("wss://purplepag.es/"); expect(relays).toContain("wss://relay.primal.net/"); diff --git a/src/services/loaders.ts b/src/services/loaders.ts index fef398e..706265e 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -52,7 +52,6 @@ function extractRelayContext(event: NostrEvent): { // Aggregator relays for better event discovery // IMPORTANT: URLs must be normalized (trailing slash, lowercase) to match RelayStateManager keys export const AGGREGATOR_RELAYS = [ - "wss://relay.nostr.band/", "wss://nos.lol/", "wss://purplepag.es/", "wss://relay.primal.net/", diff --git a/src/services/relay-selection.test.ts b/src/services/relay-selection.test.ts index b923b85..3b694f0 100644 --- a/src/services/relay-selection.test.ts +++ b/src/services/relay-selection.test.ts @@ -76,7 +76,7 @@ describe("selectRelaysForFilter", () => { const relayListEvent = createRelayListEvent(testSecretKeys[0], [ ["r", "wss://relay.damus.io"], ["r", "wss://nos.lol"], - ["r", "wss://relay.nostr.band", "read"], + ["r", "wss://purplepag.es", "read"], ]); // Add to event store @@ -99,7 +99,7 @@ describe("selectRelaysForFilter", () => { result.relays.includes("wss://nos.lol/"); expect(hasWriteRelay).toBe(true); // Should NOT include read-only relay - expect(result.relays).not.toContain("wss://relay.nostr.band/"); + expect(result.relays).not.toContain("wss://purplepag.es/"); }); it("should handle multiple authors", async () => { @@ -141,7 +141,7 @@ describe("selectRelaysForFilter", () => { const relayListEvent = createRelayListEvent(testSecretKeys[2], [ ["r", "wss://relay.damus.io", "write"], ["r", "wss://nos.lol", "read"], - ["r", "wss://relay.nostr.band", "read"], + ["r", "wss://purplepag.es", "read"], ]); eventStore.add(relayListEvent); @@ -160,7 +160,7 @@ describe("selectRelaysForFilter", () => { // Should include at least one read relay - selectOptimalRelays may pick subset const hasReadRelay = result.relays.includes("wss://nos.lol/") || - result.relays.includes("wss://relay.nostr.band/"); + result.relays.includes("wss://purplepag.es/"); expect(hasReadRelay).toBe(true); // Should NOT include write-only relay expect(result.relays).not.toContain("wss://relay.damus.io/"); diff --git a/src/services/supporters.ts b/src/services/supporters.ts new file mode 100644 index 0000000..2d867ff --- /dev/null +++ b/src/services/supporters.ts @@ -0,0 +1,365 @@ +/** + * Grimoire Supporters Singleton Service + * + * Tracks users who have zapped Grimoire by monitoring kind 9735 (zap receipt) events. + * Subscribes to relays and stores individual zap records in IndexedDB for accurate tracking. + */ + +import { Subscription } from "rxjs"; +import { firstValueFrom, timeout as rxTimeout, of } from "rxjs"; +import { catchError } from "rxjs/operators"; +import pool from "./relay-pool"; +import relayListCache from "./relay-list-cache"; +import { createTimelineLoader, addressLoader } from "./loaders"; +import { + getZapRecipient, + getZapSender, + getZapAmount, + isValidZap, + getZapRequest, +} from "applesauce-common/helpers/zap"; +import { GRIMOIRE_DONATE_PUBKEY } from "@/lib/grimoire-members"; +import type { NostrEvent } from "@/types/nostr"; +import db, { type GrimoireZap } from "./db"; + +export interface SupporterInfo { + pubkey: string; + totalSats: number; + zapCount: number; + lastZapTimestamp: number; +} + +/** + * Monthly donation goal in sats (210k sats = 0.0021 BTC) + */ +export const MONTHLY_GOAL_SATS = 210_000; + +/** + * Premium supporter threshold per month (2.1k sats) + * Users above this get special badge treatment + */ +export const PREMIUM_SUPPORTER_THRESHOLD = 2_100; + +/** + * Hardcoded relays known to have Grimoire zaps + * Used as immediate fallback for cold start before relay list loads + */ +const GRIMOIRE_ZAP_RELAYS = ["wss://nos.lol"]; + +class SupportersService { + private subscription: Subscription | null = null; + + /** + * Initialize the service - subscribe to zap receipts + * Can be called multiple times (re-initializes subscription) + */ + async init() { + // Clean up existing subscription if any + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + + // Subscribe to new zaps (will fetch relay list) + await this.subscribeToZapReceipts(); + } + + /** + * Subscribe to zap receipts for Grimoire donation pubkey + */ + private async subscribeToZapReceipts() { + try { + // Start with hardcoded relays for immediate cold start + let grimRelays = [...GRIMOIRE_ZAP_RELAYS]; + + // Fetch relay list in background (non-blocking) + // Don't await - let it happen in parallel with subscription + this.fetchAndMergeRelayList(); + + // Subscribe to zap receipts (kind 9735) for Grimoire + // Using 'p' tag filter for recipient (NIP-57 zap receipts tag the recipient) + const loader = createTimelineLoader(pool, grimRelays, [ + { + kinds: [9735], + "#p": [GRIMOIRE_DONATE_PUBKEY], + limit: 500, // Many relays reject limits over 500 + }, + ]); + + // Subscribe directly to the loader's observable + // TimelineLoader returns Observable - emits individual events from relays + const loaderSubscription = loader().subscribe({ + next: (event: NostrEvent) => { + // Process each event as it arrives from relays + this.processZapReceipt(event); + }, + error: (error) => { + console.error("[Supporters] Timeline loader error:", error); + }, + }); + + // Store subscription for cleanup + this.subscription = loaderSubscription; + } catch (error) { + console.error("[Supporters] Failed to subscribe to zap receipts:", error); + } + } + + /** + * Fetch Grimoire's relay list from kind 10002 (non-blocking) + * Returns array of relay URLs + */ + private async fetchAndMergeRelayList(): Promise { + try { + await firstValueFrom( + addressLoader({ + kind: 10002, + pubkey: GRIMOIRE_DONATE_PUBKEY, + identifier: "", + }).pipe( + rxTimeout(10000), + catchError(() => of(null)), + ), + ); + + // Give relayListCache a moment to update + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Get inbox relays from cache + const inboxRelays = await relayListCache.getInboxRelays( + GRIMOIRE_DONATE_PUBKEY, + ); + + if (inboxRelays && inboxRelays.length > 0) { + return inboxRelays; + } + + return []; + } catch (err) { + return []; + } + } + + /** + * Process a zap receipt event and store in DB + */ + private async processZapReceipt(event: NostrEvent) { + try { + // Only process valid zaps + if (!isValidZap(event)) return; + + // Double-check recipient is Grimoire + const recipient = getZapRecipient(event); + if (recipient !== GRIMOIRE_DONATE_PUBKEY) return; + + // Get sender + const sender = getZapSender(event); + if (!sender) return; + + // Check if already recorded (deduplication) + const existing = await db.grimoireZaps.get(event.id); + if (existing) return; + + // Get amount (millisats -> sats) + const amountMsats = getZapAmount(event); + const amountSats = amountMsats ? Math.floor(amountMsats / 1000) : 0; + + // Get comment from zap request + const zapRequest = getZapRequest(event); + const comment = zapRequest?.content; + + // Store in DB + const zapRecord: GrimoireZap = { + eventId: event.id, + senderPubkey: sender, + amountSats, + timestamp: event.created_at, + comment: comment || undefined, + }; + + await db.grimoireZaps.add(zapRecord); + } catch (error) { + // Silently ignore duplicate key errors (race condition protection) + if ((error as any).name !== "ConstraintError") { + console.error("[Supporters] Failed to process zap:", error); + } + } + } + + /** + * Check if a pubkey is a Grimoire supporter (async) + */ + async isSupporter(pubkey: string): Promise { + const count = await db.grimoireZaps + .where("senderPubkey") + .equals(pubkey) + .count(); + return count > 0; + } + + /** + * Get supporter info for a pubkey (efficient indexed query) + */ + async getSupporterInfo(pubkey: string): Promise { + let totalSats = 0; + let zapCount = 0; + let lastZapTimestamp = 0; + + await db.grimoireZaps + .where("senderPubkey") + .equals(pubkey) + .each((zap) => { + totalSats += zap.amountSats; + zapCount += 1; + lastZapTimestamp = Math.max(lastZapTimestamp, zap.timestamp); + }); + + if (zapCount === 0) return undefined; + + return { + pubkey, + totalSats, + zapCount, + lastZapTimestamp, + }; + } + + /** + * Get monthly supporter info for a pubkey + * Returns sats donated in last 30 days + */ + async getMonthlySupporterInfo( + pubkey: string, + ): Promise<{ totalSats: number; zapCount: number } | undefined> { + const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; + + let totalSats = 0; + let zapCount = 0; + + // Use compound index [senderPubkey+timestamp] for efficient query + await db.grimoireZaps + .where("[senderPubkey+timestamp]") + .between([pubkey, thirtyDaysAgo], [pubkey, Infinity]) + .each((zap) => { + totalSats += zap.amountSats; + zapCount += 1; + }); + + if (zapCount === 0) return undefined; + + return { totalSats, zapCount }; + } + + /** + * Check if pubkey is a premium supporter (2.1k+ sats this month) + */ + async isPremiumSupporter(pubkey: string): Promise { + const monthlyInfo = await this.getMonthlySupporterInfo(pubkey); + return (monthlyInfo?.totalSats || 0) >= PREMIUM_SUPPORTER_THRESHOLD; + } + + /** + * Get all supporters sorted by total sats (descending) + */ + async getAllSupporters(): Promise { + const supporterMap = new Map(); + + // Use Dexie iteration to avoid loading all into memory + await db.grimoireZaps.each((zap) => { + const existing = supporterMap.get(zap.senderPubkey); + if (existing) { + existing.totalSats += zap.amountSats; + existing.zapCount += 1; + existing.lastZapTimestamp = Math.max( + existing.lastZapTimestamp, + zap.timestamp, + ); + } else { + supporterMap.set(zap.senderPubkey, { + pubkey: zap.senderPubkey, + totalSats: zap.amountSats, + zapCount: 1, + lastZapTimestamp: zap.timestamp, + }); + } + }); + + return Array.from(supporterMap.values()).sort( + (a, b) => b.totalSats - a.totalSats, + ); + } + + /** + * Get total donations (all-time) using Dexie iteration + */ + async getTotalDonations(): Promise { + let total = 0; + await db.grimoireZaps.each((zap) => { + total += zap.amountSats; + }); + return total; + } + + /** + * Get donations in last 30 days using indexed query + */ + async getMonthlyDonations(): Promise { + const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; + + let total = 0; + await db.grimoireZaps + .where("timestamp") + .aboveOrEqual(thirtyDaysAgo) + .each((zap) => { + total += zap.amountSats; + }); + + return total; + } + + /** + * Get donations in current calendar month using indexed query + */ + async getCurrentMonthDonations(): Promise { + const now = new Date(); + const firstOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const firstOfMonthTimestamp = Math.floor(firstOfMonth.getTime() / 1000); + + let total = 0; + await db.grimoireZaps + .where("timestamp") + .aboveOrEqual(firstOfMonthTimestamp) + .each((zap) => { + total += zap.amountSats; + }); + + return total; + } + + /** + * Get supporter count using Dexie uniqueKeys + */ + async getSupporterCount(): Promise { + const uniquePubkeys = await db.grimoireZaps + .orderBy("senderPubkey") + .uniqueKeys(); + return uniquePubkeys.length; + } + + /** + * Cleanup when shutting down + */ + destroy() { + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + } +} + +// Export singleton instance +const supportersService = new SupportersService(); +export default supportersService; + +// Legacy export for compatibility +export const initSupporters = () => supportersService.init();