mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-15 18:07:21 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user