Files
eranos/src/hooks/useStreamPosts.ts
T
shakespeare.diy ff38bb14bc Render polls, geocaches, and color moments per their NIP specs
- PollContent (kind 1068 / NIP-88): Shows question, options with
  selectable vote UI, tallied results with percentage bars, poll type
  badge (single/multi), expiry status. Fetches kind 1018 vote events,
  deduplicates per pubkey, and allows voting when logged in.

- GeocacheContent (kind 37516 / NIP-GC): Shows cache name, type badge,
  size badge, D/T difficulty pips (1-5), description, image, and
  ROT13-decoded hint toggle. Supports verified cache indicator.

- ColorMomentContent (kind 3367 / Espy NIP): Renders color palettes
  with all 6 layout modes (horizontal, vertical, grid, star,
  checkerboard, diagonalStripes). Emoji overlay with adaptive
  light/dark text based on palette luminance. Click-to-copy hex
  swatches. Name display above palette.

- NoteCard now dispatches to the correct content component based on
  event.kind (1, 1068, 3367, 34236, 37516).

- Replaced VinesPage/useStreamVines with generic KindFeedPage/useStreamKind.
  All kind-specific routes (/vines, /polls, /treasures, /colors) use
  the same generic streaming page parameterized by kind number.

- Centralized extra-kind definitions in lib/extraKinds.ts. All feeds
  (home, search, profile, hashtag) respect feed settings for which
  extra kinds to include.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-02-17 05:24:01 -06:00

160 lines
4.9 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useState, useEffect, useMemo } from 'react';
import { useFeedSettings } from './useFeedSettings';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
interface StreamPostsOptions {
includeReplies: boolean;
mediaType: 'all' | 'images' | 'videos' | 'vines' | 'none';
}
/** Check if an event has imeta tags with image MIME types. */
function hasImageImeta(event: NostrEvent): boolean {
return event.tags.some(
(tag) => tag[0] === 'imeta' && tag.slice(1).some((part) => part.startsWith('m ') && part.split(' ')[1]?.startsWith('image/')),
);
}
/** Check if an event has imeta tags with video MIME types. */
function hasVideoImeta(event: NostrEvent): boolean {
return event.tags.some(
(tag) => tag[0] === 'imeta' && tag.slice(1).some((part) => part.startsWith('m ') && part.split(' ')[1]?.startsWith('video/')),
);
}
function filterEvent(event: NostrEvent, options: StreamPostsOptions): boolean {
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return false;
// Non-kind-1 events (extra kinds) pass through — media filters only apply to kind 1
if (event.kind !== 1) return true;
if (!options.includeReplies) {
if (event.tags.some(([name]) => name === 'e')) return false;
}
if (options.mediaType !== 'all') {
const hasImages = hasImageImeta(event);
const hasVideos = hasVideoImeta(event);
switch (options.mediaType) {
case 'images': return hasImages;
case 'videos': return hasVideos;
case 'vines': return false; // kind 1 posts aren't vines
case 'none': return !hasImages && !hasVideos;
}
}
return true;
}
/**
* Stream posts using a direct relay connection.
* When mediaType is 'vines', streams kind 34236 events instead of kind 1.
* Includes extra kinds the user has enabled in feed settings.
* Other filters are applied client-side via useMemo.
*/
export function useStreamPosts(query: string, options: StreamPostsOptions) {
const { nostr } = useNostr();
const { feedSettings } = useFeedSettings();
const [allEvents, setAllEvents] = useState<NostrEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Vines filter changes the kind queried, so it must restart the stream
const isVines = options.mediaType === 'vines';
const extraKinds = getEnabledFeedKinds(feedSettings);
const extraKindsKey = extraKinds.sort().join(',');
useEffect(() => {
const ac = new AbortController();
let alive = true;
setAllEvents([]);
setIsLoading(true);
const eventMap = new Map<string, NostrEvent>();
function addEvent(event: NostrEvent) {
if (!alive) return;
const now = Math.floor(Date.now() / 1000);
if (event.created_at > now) return;
// Addressable events (30000-39999) dedupe by pubkey+kind+d
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
const key = `${event.pubkey}:${event.kind}:${dTag}`;
const existing = eventMap.get(key);
if (existing && existing.created_at >= event.created_at) return;
eventMap.set(key, event);
} else {
if (eventMap.has(event.id)) return;
eventMap.set(event.id, event);
}
setAllEvents(Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at));
}
const relay = nostr.relay('wss://relay.ditto.pub');
// Build the kinds list: either vines-only or kind 1 + enabled extras
const kinds: number[] = isVines
? [34236]
: [1, ...extraKinds];
const baseFilter: NostrFilter = { kinds };
if (query.trim()) {
baseFilter.search = query.trim();
}
// 1. Fetch initial batch
(async () => {
try {
const events = await relay.query(
[{ ...baseFilter, limit: 40 }],
{ signal: ac.signal },
);
for (const event of events) {
addEvent(event);
}
} catch {
// abort expected
}
if (alive) setIsLoading(false);
})();
// 2. Stream new events
(async () => {
try {
const now = Math.floor(Date.now() / 1000);
for await (const msg of relay.req(
[{ ...baseFilter, since: now, limit: 100 }],
{ signal: ac.signal },
)) {
if (!alive) break;
if (msg[0] === 'EVENT') {
addEvent(msg[2]);
} else if (msg[0] === 'CLOSED') {
break;
}
}
} catch {
// abort expected
}
})();
return () => {
alive = false;
ac.abort();
};
}, [nostr, query, isVines, extraKindsKey]);
// Apply client-side filters without restarting the stream
const posts = useMemo(() => {
return allEvents.filter((event) => filterEvent(event, options));
}, [allEvents, options.includeReplies, options.mediaType]);
return { posts, isLoading };
}