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
This commit is contained in:
Claude
2026-01-19 10:04:42 +00:00
parent b4af53e26a
commit b0d0f4bdfd
4 changed files with 92 additions and 89 deletions

View File

@@ -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 (
<div className="h-full w-full flex items-center justify-center">
<div className="flex flex-col items-center gap-8">
@@ -89,7 +123,7 @@ export function GrimoireWelcome({
<p className="text-muted-foreground text-xs font-mono mb-1">
Try these commands:
</p>
{EXAMPLE_COMMANDS.map(({ command, description }) => (
{EXAMPLE_COMMANDS.map(({ command, description, showProgress }) => (
<button
key={command}
onClick={() => onExecuteCommand(command)}
@@ -101,6 +135,18 @@ export function GrimoireWelcome({
<div className="text-xs text-muted-foreground mt-0.5">
{description}
</div>
{showProgress && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Monthly goal</span>
<span>
{formatSats(monthlyDonations)} /{" "}
{formatSats(MONTHLY_GOAL_SATS)} sats
</span>
</div>
<Progress value={goalProgress} className="h-1" />
</div>
)}
</button>
))}
</div>

View File

@@ -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}
>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<Zap className="size-4 text-yellow-500" />
<span className="text-sm font-medium">Support Grimoire</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
refreshZaps();
}}
disabled={isRefreshingZaps}
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
title="Refresh donation stats"
>
<RefreshCw
className={cn("size-3", isRefreshingZaps && "animate-spin")}
/>
</button>
<div className="flex items-center gap-2 mb-1.5">
<Zap className="size-4 text-yellow-500" />
<span className="text-sm font-medium">Support Grimoire</span>
</div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">Monthly goal</span>

View File

@@ -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<string>();
// 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 };

View File

@@ -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<Set<string>>(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();