This commit is contained in:
Alejandro Gómez
2025-11-26 09:47:21 +01:00
commit cd41034b2f
112 changed files with 18581 additions and 0 deletions

104
src/hooks/useAccountSync.ts Normal file
View File

@@ -0,0 +1,104 @@
import { useEffect } from "react";
import { useObservableMemo, useEventStore } from "applesauce-react/hooks";
import accounts from "@/services/accounts";
import { useGrimoire } from "@/core/state";
import { getInboxes, getOutboxes } from "applesauce-core/helpers";
import { addressLoader } from "@/services/loaders";
import type { RelayInfo, UserRelays } from "@/types/app";
/**
* Hook that syncs active account with Grimoire state and fetches relay lists
*/
export function useAccountSync() {
const { state, setActiveAccount, setActiveAccountRelays } = useGrimoire();
const eventStore = useEventStore();
// Watch active account from accounts service
const activeAccount = useObservableMemo(() => accounts.active$, []);
// Sync active account pubkey to state
useEffect(() => {
console.log("useAccountSync: activeAccount changed", activeAccount?.pubkey);
if (activeAccount?.pubkey !== state.activeAccount?.pubkey) {
console.log(
"useAccountSync: setting active account",
activeAccount?.pubkey,
);
setActiveAccount(activeAccount?.pubkey);
}
}, [activeAccount?.pubkey, state.activeAccount?.pubkey, setActiveAccount]);
// Fetch and watch relay list (kind 10002) when account changes
useEffect(() => {
if (!activeAccount?.pubkey) {
console.log("useAccountSync: no active account, skipping relay fetch");
return;
}
const pubkey = activeAccount.pubkey;
console.log("useAccountSync: fetching relay list for", pubkey);
// Subscribe to kind 10002 (relay list)
const subscription = addressLoader({
kind: 10002,
pubkey,
identifier: "",
}).subscribe();
// Watch for relay list event in store
const storeSubscription = eventStore
.replaceable(10002, pubkey, "")
.subscribe((relayListEvent) => {
console.log(
"useAccountSync: relay list event received",
relayListEvent,
);
if (!relayListEvent) return;
// Parse inbox and outbox relays
const inboxRelays = getInboxes(relayListEvent);
const outboxRelays = getOutboxes(relayListEvent);
// Get all relays from tags
const allRelays: RelayInfo[] = [];
const seenUrls = new Set<string>();
for (const tag of relayListEvent.tags) {
if (tag[0] === "r") {
const url = tag[1];
if (seenUrls.has(url)) continue;
seenUrls.add(url);
const type = tag[2];
allRelays.push({
url,
read: !type || type === "read",
write: !type || type === "write",
});
}
}
const relays: UserRelays = {
inbox: inboxRelays.map((url) => ({
url,
read: true,
write: false,
})),
outbox: outboxRelays.map((url) => ({
url,
read: false,
write: true,
})),
all: allRelays,
};
console.log("useAccountSync: parsed relays", relays);
setActiveAccountRelays(relays);
});
return () => {
subscription.unsubscribe();
storeSubscription.unsubscribe();
};
}, [activeAccount?.pubkey, eventStore, setActiveAccountRelays]);
}

72
src/hooks/useNip.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import db from "@/services/db";
import { getNipUrl } from "@/constants/nips";
interface UseNipResult {
content: string | null;
loading: boolean;
error: Error | null;
}
export function useNip(nipId: string): UseNipResult {
const [error, setError] = useState<Error | null>(null);
const [isFetching, setIsFetching] = useState(false);
// Live query that reactively updates when the NIP is cached
const cached = useLiveQuery(() => db.nips.get(nipId), [nipId]);
useEffect(() => {
// If we already have it cached or are currently fetching, don't fetch again
if (cached || isFetching) return;
let isMounted = true;
setIsFetching(true);
async function fetchNip() {
try {
setError(null);
// Fetch from GitHub
const url = getNipUrl(nipId);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch NIP-${nipId}: ${response.statusText}`,
);
}
const markdown = await response.text();
// Cache the result (live query will auto-update)
await db.nips.put({
id: nipId,
content: markdown,
fetchedAt: Date.now(),
});
if (isMounted) {
setIsFetching(false);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err : new Error("Unknown error"));
setIsFetching(false);
}
}
}
fetchNip();
return () => {
isMounted = false;
};
}, [nipId, cached, isFetching]);
return {
content: cached?.content ?? null,
loading: !cached && isFetching,
error,
};
}

20
src/hooks/useNip05.ts Normal file
View File

@@ -0,0 +1,20 @@
import db from "@/services/db";
import { queryProfile } from "nostr-tools/nip05";
import { useLiveQuery } from "dexie-react-hooks";
import { useEffect } from "react";
export function useNip05(nip05: string) {
const resolved = useLiveQuery(() => db.nip05.get(nip05), [nip05]);
useEffect(() => {
if (resolved) return;
queryProfile(nip05).then((result) => {
if (result) {
db.nip05.put({
nip05,
pubkey: result.pubkey,
});
}
});
}, [resolved, nip05]);
return resolved?.pubkey;
}

124
src/hooks/useNostrEvent.ts Normal file
View File

@@ -0,0 +1,124 @@
import { useEffect } from "react";
import type { EventPointer, AddressPointer } from "nostr-tools/nip19";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { eventLoader, addressLoader } from "@/services/loaders";
import type { NostrEvent } from "@/types/nostr";
/**
* Type guard to check if pointer is an EventPointer
*/
function isEventPointer(
pointer: EventPointer | AddressPointer,
): pointer is EventPointer {
return "id" in pointer;
}
/**
* Type guard to check if pointer is an AddressPointer
*/
function isAddressPointer(
pointer: EventPointer | AddressPointer,
): pointer is AddressPointer {
return "kind" in pointer && "pubkey" in pointer;
}
/**
* Unified hook for fetching Nostr events by pointer
* Supports string ID, EventPointer, and AddressPointer
* @param pointer - string ID, EventPointer, or AddressPointer
* @returns Event or undefined
*/
export function useNostrEvent(
pointer:
| string
| EventPointer
| AddressPointer
| { kind: number; pubkey: string; identifier: string }
| undefined,
): NostrEvent | undefined {
const eventStore = useEventStore();
// Watch event store for the specific event
const event = useObservableMemo(() => {
if (!pointer) return undefined;
// Handle string ID
if (typeof pointer === "string") {
return eventStore.event(pointer);
}
if (isEventPointer(pointer)) {
// For EventPointer, query by ID
return eventStore.event(pointer.id);
} else if (isAddressPointer(pointer)) {
// For AddressPointer, query replaceable event
return eventStore.replaceable(
pointer.kind,
pointer.pubkey,
pointer.identifier || "",
);
}
return undefined;
}, [pointer]);
// Trigger event loading with appropriate loader
// Use JSON.stringify for dependency to handle object changes
const pointerKey = pointer
? typeof pointer === "string"
? pointer
: JSON.stringify(pointer)
: null;
useEffect(() => {
if (!pointer) return;
// Handle string ID
if (typeof pointer === "string") {
console.log("[useNostrEvent] Loading event by ID:", pointer);
const subscription = eventLoader({ id: pointer }).subscribe();
return () => subscription.unsubscribe();
}
if (isEventPointer(pointer)) {
console.log("[useNostrEvent] Loading event by EventPointer:", pointer);
const subscription = eventLoader(pointer).subscribe();
return () => subscription.unsubscribe();
} else if (isAddressPointer(pointer)) {
console.log("[useNostrEvent] Loading event by AddressPointer:", pointer);
const subscription = addressLoader(pointer).subscribe({
next: (event) =>
console.log("[useNostrEvent] Received event:", event.id),
error: (err) => console.error("[useNostrEvent] Error loading:", err),
complete: () => console.log("[useNostrEvent] Loading complete"),
});
return () => {
console.log("[useNostrEvent] Unsubscribing from addressLoader");
subscription.unsubscribe();
};
} else {
console.warn("[useNostrEvent] Unknown pointer type:", pointer);
}
}, [pointerKey]);
return event;
}
/**
* Convenience hook for fetching events by ID only
* @param eventId - Event ID to fetch
* @param relayUrl - Optional relay URL hint
* @returns Event or undefined
*/
export function useEventById(
eventId: string | undefined,
relayUrl?: string,
): NostrEvent | undefined {
const pointer = eventId
? ({
id: eventId,
relays: relayUrl ? [relayUrl] : undefined,
} as EventPointer)
: undefined;
return useNostrEvent(pointer);
}

32
src/hooks/useProfile.ts Normal file
View File

@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { kinds } from "nostr-tools";
import { profileLoader } from "@/services/loaders";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { ProfileContent } from "applesauce-core/helpers";
import { ProfileModel } from "applesauce-core/models/profile";
export function useProfile(pubkey: string): ProfileContent | undefined {
const eventStore = useEventStore();
const profile = useObservableMemo(
() => eventStore.model(ProfileModel, pubkey),
[eventStore, pubkey],
);
// Fetch profile if not in store (only runs once per pubkey)
useEffect(() => {
if (profile) return; // Already have the event
const sub = profileLoader({ kind: kinds.Metadata, pubkey }).subscribe({
next: (fetchedEvent) => {
if (fetchedEvent) {
eventStore.add(fetchedEvent);
}
},
});
return () => sub.unsubscribe();
}, [pubkey, eventStore]); // Removed event and loading from deps
return profile;
}

128
src/hooks/useReqTimeline.ts Normal file
View File

@@ -0,0 +1,128 @@
import { useState, useEffect, useMemo } from "react";
import pool from "@/services/relay-pool";
import type { NostrEvent, Filter } from "nostr-tools";
interface UseReqTimelineOptions {
limit?: number;
stream?: boolean;
}
interface UseReqTimelineReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
eoseReceived: boolean;
}
/**
* Hook for REQ command - queries ONLY specified relays using pool.req()
* Stores results in memory (not EventStore) and returns them sorted by created_at
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter object
* @param relays - Array of relay URLs (ONLY these relays will be queried)
* @param options - Additional options like limit and stream (keep connection open after EOSE)
* @returns Object containing events array (sorted newest first), loading state, and error
*/
export function useReqTimeline(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseReqTimelineOptions = { limit: 50 },
): UseReqTimelineReturn {
const { limit, stream = false } = options;
const [events, setEvents] = useState<NostrEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [eoseReceived, setEoseReceived] = useState(false);
// Use pool.req() directly to query relays
useEffect(() => {
if (relays.length === 0) {
setLoading(false);
setEvents([]);
return;
}
console.log("REQ: Starting query", { relays, filters, limit, stream });
setLoading(true);
setError(null);
setEoseReceived(false);
const collectedEvents = new Map<string, NostrEvent>();
// Normalize filters to array
const filterArray = Array.isArray(filters) ? filters : [filters];
// Add limit to filters if specified
const filtersWithLimit = filterArray.map((f) => ({
...f,
limit: limit || f.limit,
}));
// Use pool.req() for direct relay querying
// pool.req() returns an Observable of events
const observable = pool.req(relays, filtersWithLimit);
const subscription = observable.subscribe(
(response) => {
// Response can be an event or 'EOSE' string
if (typeof response === "string") {
console.log("REQ: EOSE received");
setEoseReceived(true);
if (!stream) {
setLoading(false);
}
} else {
const event = response as NostrEvent;
console.log("REQ: Event received", event.id);
// Use Map to deduplicate by event ID
collectedEvents.set(event.id, event);
// Update state with deduplicated events
setEvents(Array.from(collectedEvents.values()));
}
},
(err: Error) => {
console.error("REQ: Error", err);
setError(err);
setLoading(false);
},
() => {
console.log("REQ: Query complete", {
total: collectedEvents.size,
stream,
});
// Only set loading to false if not streaming
if (!stream) {
setLoading(false);
}
},
);
// Set a timeout to prevent infinite loading (only for non-streaming queries)
const timeout = !stream
? setTimeout(() => {
console.warn("REQ: Query timeout, forcing completion");
setLoading(false);
}, 10000)
: undefined;
return () => {
if (timeout) {
clearTimeout(timeout);
}
subscription.unsubscribe();
};
}, [id, JSON.stringify(filters), relays.join(","), limit, stream]);
// Sort events by created_at (newest first)
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => b.created_at - a.created_at);
}, [events]);
return {
events: sortedEvents,
loading,
error,
eoseReceived,
};
}

80
src/hooks/useTimeline.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from "react";
import type { NostrEvent, Filter } from "nostr-tools";
import { useEventStore, useObservableMemo } from "applesauce-react/hooks";
import { createTimelineLoader } from "@/services/loaders";
import pool from "@/services/relay-pool";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
interface UseTimelineOptions {
limit?: number;
}
interface UseTimelineReturn {
events: NostrEvent[];
loading: boolean;
error: Error | null;
}
/**
* Hook for subscribing to a timeline of events from relays
* Uses applesauce loaders for efficient event loading and caching
* @param id - Unique identifier for this timeline (for caching)
* @param filters - Nostr filter object
* @param relays - Array of relay URLs
* @param options - Additional options like limit
* @returns Object containing events array, loading state, and error
*/
export function useTimeline(
id: string,
filters: Filter | Filter[],
relays: string[],
options: UseTimelineOptions = { limit: 20 },
): UseTimelineReturn {
const { limit } = options;
const eventStore = useEventStore();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Load events into store
useEffect(() => {
if (relays.length === 0) return;
const loader = createTimelineLoader(
pool,
relays.concat(AGGREGATOR_RELAYS),
filters,
{
eventStore,
limit,
},
);
setLoading(true);
setError(null);
const subscription = loader().subscribe({
error: (err: Error) => {
console.error("Timeline error:", err);
setError(err);
setLoading(false);
},
complete: () => {
setLoading(false);
},
});
return () => subscription.unsubscribe();
}, [id, relays.length, limit]);
// Watch store for matching events
const timeline = useObservableMemo(() => {
return eventStore.timeline(filters, false);
}, [id]);
const hasItems = timeline ? timeline.length > 0 : false;
return {
events: timeline || [],
loading: hasItems ? false : loading,
error,
};
}