From b0d0f4bdfdc780ac6ddd5cce7053f323c5f0bff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Jan 2026 10:04:42 +0000 Subject: [PATCH] refactor: replace BehaviorSubject with Dexie useLiveQuery for reactive supporter tracking Replace manual BehaviorSubject pattern with Dexie's built-in useLiveQuery hook for reactive database queries. This simplifies the code and leverages Dexie's optimized change detection. Changes: - Remove BehaviorSubject from SupportersService - Remove refreshSupporters() method and all calls to it - Update useIsSupporter hook to use useLiveQuery for supporter pubkeys - Update GrimoireWelcome to use useLiveQuery for monthly donations - Update UserMenu to use useLiveQuery for monthly donations - Remove unused imports (cn, useEffect, useState) and fields (initialized) Benefits: - Less code to maintain (no manual observable management) - Automatic reactivity when DB changes - Better performance with Dexie's built-in change detection --- src/components/GrimoireWelcome.tsx | 52 +++++++++++++++++++++-- src/components/nostr/user-menu.tsx | 67 +++++++++--------------------- src/hooks/useIsSupporter.ts | 17 ++++++-- src/services/supporters.ts | 45 ++++---------------- 4 files changed, 92 insertions(+), 89 deletions(-) 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/user-menu.tsx b/src/components/nostr/user-menu.tsx index 857b790..7bd0fec 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -14,8 +14,9 @@ import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; import { getDisplayName } from "@/lib/nostr-utils"; import { useGrimoire } from "@/core/state"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { useLiveQuery } from "dexie-react-hooks"; +import db from "@/services/db"; import { DropdownMenu, DropdownMenuContent, @@ -41,7 +42,7 @@ import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; import LoginDialog from "./LoginDialog"; import ConnectWalletDialog from "@/components/ConnectWalletDialog"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import { useTheme } from "@/lib/themes"; import { toast } from "sonner"; import { useWallet } from "@/hooks/useWallet"; @@ -50,7 +51,7 @@ import { GRIMOIRE_DONATE_PUBKEY, GRIMOIRE_LIGHTNING_ADDRESS, } from "@/lib/grimoire-members"; -import supportersService, { MONTHLY_GOAL_SATS } from "@/services/supporters"; +import { MONTHLY_GOAL_SATS } from "@/services/supporters"; function UserAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); @@ -94,32 +95,19 @@ export default function UserMenu() { const [showWalletInfo, setShowWalletInfo] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); - // Subscribe to supporters to trigger re-render when donations change - const supporters = use$(supportersService.supporters$); - - // Load monthly donations async - const [monthlyDonations, setMonthlyDonations] = useState(0); - const [isRefreshingZaps, setIsRefreshingZaps] = useState(false); - - useEffect(() => { - supportersService.getMonthlyDonations().then(setMonthlyDonations); - }, [supporters]); // Reload when supporters change - - // Manual refresh zaps - async function refreshZaps() { - setIsRefreshingZaps(true); - try { - // Re-fetch Grimoire relay list and reload timeline - await supportersService.init(); - // Update monthly donations - const donations = await supportersService.getMonthlyDonations(); - setMonthlyDonations(donations); - } catch (error) { - console.error("Failed to refresh zaps:", error); - } finally { - setIsRefreshingZaps(false); - } - } + // 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; @@ -442,24 +430,9 @@ export default function UserMenu() { className="px-2 py-2 cursor-crosshair hover:bg-accent/50 transition-colors" onClick={openDonate} > -
-
- - Support Grimoire -
- +
+ + Support Grimoire
Monthly goal diff --git a/src/hooks/useIsSupporter.ts b/src/hooks/useIsSupporter.ts index 65bed57..63dd011 100644 --- a/src/hooks/useIsSupporter.ts +++ b/src/hooks/useIsSupporter.ts @@ -2,9 +2,10 @@ * Hook to check if a user is a Grimoire supporter */ -import { use$ } from "applesauce-react/hooks"; +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 @@ -15,9 +16,19 @@ export function useIsSupporter(pubkey: string | undefined): { isSupporter: boolean; isPremiumSupporter: boolean; } { - const supporters = use$(supportersService.supporters$); + // 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)) { @@ -26,7 +37,7 @@ export function useIsSupporter(pubkey: string | undefined): { } supportersService.isPremiumSupporter(pubkey).then(setIsPremium); - }, [pubkey, supporters]); + }, [pubkey, supporters.size]); // Use supporters.size to avoid Set equality issues if (!pubkey) { return { isSupporter: false, isPremiumSupporter: false }; diff --git a/src/services/supporters.ts b/src/services/supporters.ts index 1578d52..2f87295 100644 --- a/src/services/supporters.ts +++ b/src/services/supporters.ts @@ -5,7 +5,7 @@ * Subscribes to relays and stores individual zap records in IndexedDB for accurate tracking. */ -import { BehaviorSubject, Subscription } from "rxjs"; +import { Subscription } from "rxjs"; import { firstValueFrom, timeout as rxTimeout, of } from "rxjs"; import { catchError } from "rxjs/operators"; import eventStore from "./event-store"; @@ -44,48 +44,26 @@ export const PREMIUM_SUPPORTER_THRESHOLD = 2_100; class SupportersService { private subscription: Subscription | null = null; - private initialized = false; - - /** - * Observable set of supporter pubkeys for reactive UI - * Updated whenever a new zap is recorded - */ - public readonly supporters$ = new BehaviorSubject>(new Set()); /** * Initialize the service - subscribe to zap receipts + * Can be called multiple times (re-initializes subscription) */ async init() { - if (this.initialized) return; - this.initialized = true; - console.log("[Supporters] Initializing..."); - // Load existing supporters from DB - await this.refreshSupporters(); + // Clean up existing subscription if any + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } - // Subscribe to new zaps + // Subscribe to new zaps (will fetch relay list) await this.subscribeToZapReceipts(); console.log("[Supporters] Initialized"); } - /** - * Load supporters from DB and update observable - */ - private async refreshSupporters() { - try { - // Get unique sender pubkeys efficiently using Dexie uniqueKeys - const uniquePubkeys = await db.grimoireZaps - .orderBy("senderPubkey") - .uniqueKeys(); - - this.supporters$.next(new Set(uniquePubkeys as string[])); - } catch (error) { - console.error("[Supporters] Failed to refresh from DB:", error); - } - } - /** * Subscribe to zap receipts for Grimoire donation pubkey */ @@ -212,9 +190,6 @@ class SupportersService { console.log( `[Supporters] Recorded zap: ${amountSats} sats from ${sender.slice(0, 8)}`, ); - - // Refresh supporters (updates observable) - await this.refreshSupporters(); } catch (error) { // Silently ignore duplicate key errors (race condition protection) if ((error as any).name !== "ConstraintError") { @@ -391,7 +366,6 @@ class SupportersService { this.subscription.unsubscribe(); this.subscription = null; } - this.initialized = false; } } @@ -399,6 +373,5 @@ class SupportersService { const supportersService = new SupportersService(); export default supportersService; -// Legacy exports for compatibility -export const supporters$ = supportersService.supporters$; +// Legacy export for compatibility export const initSupporters = () => supportersService.init();