mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
353 lines
9.8 KiB
TypeScript
353 lines
9.8 KiB
TypeScript
// lib/hooks/useFeedEvents.ts
|
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
import { NDKEvent, NDKFilter, NDKSubscription } from '@nostr-dev-kit/ndk-mobile';
|
|
import { useNDK } from '@/lib/hooks/useNDK';
|
|
import { AnyFeedEntry, FeedOptions, UpdateEntryFn } from '@/types/feed';
|
|
import { POWR_EVENT_KINDS } from '@/types/nostr-workout';
|
|
import {
|
|
parseWorkoutRecord,
|
|
parseExerciseTemplate,
|
|
parseWorkoutTemplate,
|
|
parseSocialPost,
|
|
parseLongformContent
|
|
} from '@/types/nostr-workout';
|
|
|
|
// Default sort function - most recent first
|
|
const defaultSortFn = (a: AnyFeedEntry, b: AnyFeedEntry) => b.timestamp - a.timestamp;
|
|
|
|
export function useFeedEvents(
|
|
filters: NDKFilter[] | false,
|
|
options: FeedOptions = {}
|
|
) {
|
|
const { ndk } = useNDK();
|
|
const [entries, setEntries] = useState<AnyFeedEntry[]>([]);
|
|
const [newEntries, setNewEntries] = useState<AnyFeedEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [eose, setEose] = useState(false);
|
|
|
|
// Add state to control subscription
|
|
const [subscriptionEnabled, setSubscriptionEnabled] = useState(true);
|
|
const isResetting = useRef(false);
|
|
|
|
// Keep references to entries and options
|
|
const entriesRef = useRef<Record<string, AnyFeedEntry>>({});
|
|
const subscriptionRef = useRef<NDKSubscription | null>(null);
|
|
const seenEventsRef = useRef<Set<string>>(new Set());
|
|
const quotedEventsRef = useRef<Set<string>>(new Set());
|
|
|
|
// Options with defaults
|
|
const {
|
|
subId = 'feed',
|
|
enabled = true,
|
|
filterFn,
|
|
sortFn = defaultSortFn
|
|
} = options;
|
|
|
|
// Clear all new entries
|
|
const clearNewEntries = useCallback(() => {
|
|
setNewEntries([]);
|
|
}, []);
|
|
|
|
// Reset feed
|
|
const resetFeed = useCallback(async () => {
|
|
// Set a flag to prevent multiple resets in progress
|
|
if (isResetting.current) {
|
|
console.log('[Feed] Reset already in progress, ignoring request');
|
|
return;
|
|
}
|
|
|
|
console.log('[Feed] Starting reset');
|
|
isResetting.current = true;
|
|
|
|
try {
|
|
// Cancel the existing subscription
|
|
if (subscriptionRef.current) {
|
|
subscriptionRef.current.stop();
|
|
subscriptionRef.current = null;
|
|
}
|
|
|
|
// Reset state
|
|
entriesRef.current = {};
|
|
seenEventsRef.current.clear();
|
|
quotedEventsRef.current.clear();
|
|
setEntries([]);
|
|
setNewEntries([]);
|
|
setEose(false);
|
|
setLoading(true);
|
|
|
|
// Important: Add a small delay before toggling subscription state
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Force a new subscription by toggling enabled
|
|
setSubscriptionEnabled(false);
|
|
|
|
// Another small delay
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Re-enable the subscription
|
|
setSubscriptionEnabled(true);
|
|
|
|
console.log('[Feed] Reset complete');
|
|
return Promise.resolve();
|
|
} catch (error) {
|
|
console.error('[Feed] Error during reset:', error);
|
|
throw error;
|
|
} finally {
|
|
isResetting.current = false;
|
|
}
|
|
}, []);
|
|
|
|
// Update an entry
|
|
const updateEntry: UpdateEntryFn = useCallback((id, updater) => {
|
|
const existingEntry = entriesRef.current[id];
|
|
if (!existingEntry) return;
|
|
|
|
const updatedEntry = updater(existingEntry);
|
|
entriesRef.current[id] = updatedEntry;
|
|
|
|
// Update state for re-render
|
|
setEntries(Object.values(entriesRef.current).sort(sortFn));
|
|
}, [sortFn]);
|
|
|
|
// Process event and create entry
|
|
const processEvent = useCallback((event: NDKEvent) => {
|
|
// Skip if no ID
|
|
if (!event.id) return;
|
|
|
|
const eventId = event.id;
|
|
const timestamp = event.created_at ? event.created_at * 1000 : Date.now();
|
|
|
|
// Skip if we've already seen this event
|
|
if (seenEventsRef.current.has(eventId)) return;
|
|
seenEventsRef.current.add(eventId);
|
|
|
|
// Check for quoted events to avoid duplicates
|
|
if (event.kind === 1) {
|
|
// Track any direct e-tag references
|
|
event.tags
|
|
.filter(tag => tag[0] === 'e')
|
|
.forEach(tag => {
|
|
if (tag[1]) quotedEventsRef.current.add(tag[1]);
|
|
});
|
|
|
|
// Also check for quoted content in NIP-27 format
|
|
if (event.content) {
|
|
const nostrUriMatches = event.content.match(/nostr:(note1|nevent1|naddr1)[a-z0-9]+/g);
|
|
if (nostrUriMatches) {
|
|
// Add these to quotedEvents
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create base entry
|
|
let entry: AnyFeedEntry | null = null;
|
|
|
|
try {
|
|
switch (event.kind) {
|
|
case POWR_EVENT_KINDS.WORKOUT_RECORD: // 1301
|
|
entry = {
|
|
id: `workout-${eventId}`,
|
|
eventId,
|
|
event,
|
|
timestamp,
|
|
type: 'workout',
|
|
content: parseWorkoutRecord(event)
|
|
};
|
|
break;
|
|
|
|
case POWR_EVENT_KINDS.EXERCISE_TEMPLATE: // 33401
|
|
entry = {
|
|
id: `exercise-${eventId}`,
|
|
eventId,
|
|
event,
|
|
timestamp,
|
|
type: 'exercise',
|
|
content: parseExerciseTemplate(event)
|
|
};
|
|
break;
|
|
|
|
case POWR_EVENT_KINDS.WORKOUT_TEMPLATE: // 33402
|
|
entry = {
|
|
id: `template-${eventId}`,
|
|
eventId,
|
|
event,
|
|
timestamp,
|
|
type: 'template',
|
|
content: parseWorkoutTemplate(event)
|
|
};
|
|
break;
|
|
|
|
case POWR_EVENT_KINDS.SOCIAL_POST: // 1
|
|
entry = {
|
|
id: `social-${eventId}`,
|
|
eventId,
|
|
event,
|
|
timestamp,
|
|
type: 'social',
|
|
content: parseSocialPost(event)
|
|
};
|
|
break;
|
|
|
|
case 30023: // Published article
|
|
case 30024: // Draft article
|
|
entry = {
|
|
id: `article-${eventId}`,
|
|
eventId,
|
|
event,
|
|
timestamp,
|
|
type: 'article',
|
|
content: parseLongformContent(event)
|
|
};
|
|
break;
|
|
|
|
default:
|
|
// Skip unsupported event kinds
|
|
return;
|
|
}
|
|
|
|
// Apply filter function if provided
|
|
if (filterFn && !filterFn(entry)) {
|
|
return;
|
|
}
|
|
|
|
// Add entry to our records
|
|
entriesRef.current[entry.id] = entry;
|
|
|
|
// Update entries state
|
|
const updatedEntries = Object.values(entriesRef.current).sort(sortFn);
|
|
setEntries(updatedEntries);
|
|
|
|
// Add to new entries if we've already received EOSE
|
|
if (eose && entry) {
|
|
// Explicitly check and assert entry is not null
|
|
const nonNullEntry: AnyFeedEntry = entry; // This will catch if entry is incorrectly typed
|
|
|
|
setNewEntries(prev => {
|
|
const entries: AnyFeedEntry[] = [...prev];
|
|
entries.push(nonNullEntry);
|
|
return entries;
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error processing event:', error, event);
|
|
}
|
|
}, [eose, filterFn, sortFn]);
|
|
|
|
// Function to fetch specific event types (e.g., POWR Packs)
|
|
const fetchSpecificEvents = useCallback(async (
|
|
specificFilters: NDKFilter[],
|
|
options: {
|
|
limit?: number;
|
|
processFn?: (event: NDKEvent) => any;
|
|
} = {}
|
|
) => {
|
|
if (!ndk || !specificFilters || specificFilters.length === 0) return [];
|
|
|
|
const { limit = 50, processFn } = options;
|
|
const results: any[] = [];
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
for (const filter of specificFilters) {
|
|
// Ensure we have a reasonable limit
|
|
const filterWithLimit = { ...filter, limit };
|
|
|
|
// Fetch events
|
|
const events = await ndk.fetchEvents(filterWithLimit);
|
|
|
|
if (events.size > 0) {
|
|
// Process events if a processing function is provided
|
|
if (processFn) {
|
|
for (const event of events) {
|
|
try {
|
|
const processed = processFn(event);
|
|
if (processed) results.push(processed);
|
|
} catch (err) {
|
|
console.error('Error processing event:', err);
|
|
}
|
|
}
|
|
} else {
|
|
// Otherwise just add the events
|
|
for (const event of events) {
|
|
results.push(event);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
} catch (error) {
|
|
console.error('Error fetching specific events:', error);
|
|
return [];
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [ndk]);
|
|
|
|
// Subscribe to events
|
|
useEffect(() => {
|
|
if (!ndk || !filters || !enabled || !subscriptionEnabled) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Clean up any existing subscription
|
|
if (subscriptionRef.current) {
|
|
subscriptionRef.current.stop();
|
|
subscriptionRef.current = null;
|
|
}
|
|
|
|
setLoading(true);
|
|
setEose(false);
|
|
|
|
// Track component mount state
|
|
let isMounted = true;
|
|
|
|
const subscription = ndk.subscribe(filters, { subId });
|
|
subscriptionRef.current = subscription;
|
|
|
|
// Event handler
|
|
const handleEvent = (event: NDKEvent) => {
|
|
if (isMounted) {
|
|
processEvent(event);
|
|
}
|
|
};
|
|
|
|
// EOSE handler
|
|
const handleEose = () => {
|
|
if (isMounted) {
|
|
setLoading(false);
|
|
setEose(true);
|
|
setNewEntries([]);
|
|
}
|
|
};
|
|
|
|
subscription.on('event', handleEvent);
|
|
subscription.on('eose', handleEose);
|
|
|
|
// Cleanup
|
|
return () => {
|
|
isMounted = false;
|
|
if (subscription) {
|
|
subscription.off('event', handleEvent);
|
|
subscription.off('eose', handleEose);
|
|
subscription.stop();
|
|
}
|
|
subscriptionRef.current = null;
|
|
};
|
|
}, [ndk, filters, enabled, subscriptionEnabled, subId, processEvent]);
|
|
|
|
// Return sorted entries and helpers
|
|
return {
|
|
entries,
|
|
newEntries,
|
|
loading,
|
|
eose,
|
|
updateEntry,
|
|
clearNewEntries,
|
|
resetFeed,
|
|
fetchSpecificEvents
|
|
};
|
|
} |