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();