mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-23 21:38:16 +02:00
👶
This commit is contained in:
104
src/hooks/useAccountSync.ts
Normal file
104
src/hooks/useAccountSync.ts
Normal 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
72
src/hooks/useNip.ts
Normal 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
20
src/hooks/useNip05.ts
Normal 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
124
src/hooks/useNostrEvent.ts
Normal 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
32
src/hooks/useProfile.ts
Normal 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
128
src/hooks/useReqTimeline.ts
Normal 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
80
src/hooks/useTimeline.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user