Files
eranos/src/lib/feedUtils.ts
T

117 lines
4.6 KiB
TypeScript

import type { NostrEvent } from '@nostrify/nostrify';
/**
* Minimum gap (in seconds) between consecutive events to be considered an
* out-of-sync boundary. If a relay returns events spanning a large time
* range (e.g., 10h newest → 4d oldest), there will be a large gap between
* the "main cluster" and the outliers from the stale relay.
*/
const MIN_GAP_SECONDS = 6 * 60 * 60; // 6 hours
/**
* Computes a safe pagination cursor from a set of events.
*
* When querying multiple relays, a stale relay may return very old events
* alongside recent ones. Using the absolute oldest timestamp as the cursor
* would skip everything in between. This function detects large gaps in the
* timestamp distribution and returns the oldest timestamp from the main
* (most recent) cluster, ignoring outliers below the gap.
*
* All events are still returned and displayed — only the cursor is adjusted.
*/
export function getPaginationCursor(events: NostrEvent[]): number {
if (events.length === 0) return Math.floor(Date.now() / 1000);
if (events.length === 1) return events[0].created_at;
// Sort descending (newest first).
const sorted = events.map((e) => e.created_at).sort((a, b) => b - a);
// Walk from newest to oldest, find the first gap larger than the threshold.
for (let i = 0; i < sorted.length - 1; i++) {
const gap = sorted[i] - sorted[i + 1];
if (gap >= MIN_GAP_SECONDS) {
// The cursor is the timestamp just above the gap (the oldest event
// in the main cluster). Events below the gap are outliers.
return sorted[i];
}
}
// No large gap found — all events are in one cluster.
return sorted[sorted.length - 1];
}
/** The set of kind numbers that represent reposts (kind 6 for notes, kind 16 for everything else). */
export const REPOST_KINDS = new Set([6, 16]);
/** Check if a kind number is a repost kind (6 or 16). */
export function isRepostKind(kind: number): boolean {
return REPOST_KINDS.has(kind);
}
/**
* Returns the correct repost kind for a given event.
* Kind 6 is only for reposting kind 1 text notes; kind 16 is for everything else.
*/
export function getRepostKind(originalEventKind: number): number {
return originalEventKind === 1 ? 6 : 16;
}
/** A feed item — either a direct post or a repost wrapping the original event. */
export interface FeedItem {
/** The event to display (original note). */
event: NostrEvent;
/** If this item is a repost, the pubkey of the person who reposted it. */
repostedBy?: string;
/** Sort timestamp — uses the repost timestamp when present for correct ordering. */
sortTimestamp: number;
}
/** d-tags reserved by NIP-51 for other purposes — hide these kind 30000 events from feeds. */
const DEPRECATED_DTAGS = new Set(['mute', 'pin', 'bookmark', 'communities']);
/** Returns true if a kind 30000 event is a deprecated/junk list that should be hidden. */
function isDeprecatedFollowSet(event: NostrEvent): boolean {
if (event.kind !== 30000) return false;
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
if (DEPRECATED_DTAGS.has(dTag)) return true;
const hasPTags = event.tags.some(([n]) => n === 'p');
const hasTitle = event.tags.some(([n]) => n === 'title' || n === 'name');
if (!hasPTags && !hasTitle) return true;
return false;
}
/**
* Returns true if a feed event should be hidden at the feed level.
* This pre-filters events BEFORE they are rendered as NoteCards,
* preventing unnecessary component mounts and layout shifts from
* components that would return null.
*/
export function shouldHideFeedEvent(event: NostrEvent): boolean {
// Deprecated kind 30000 follow sets
if (isDeprecatedFollowSet(event)) return true;
// Unlisted magic decks (kind 37381)
if (event.kind === 37381 && event.tags.some(([n, v]) => n === 't' && v === 'unlisted')) return true;
// Hidden treasures (kind 37516)
if (event.kind === 37516 && event.tags.some(([n, v]) => n === 't' && v === 'hidden')) return true;
// Emoji packs (kind 30030) without at least one valid emoji tag
if (event.kind === 30030 && !event.tags.some(([n, sc, url]) => n === 'emoji' && sc && url)) return true;
return false;
}
/**
* Tries to parse the original event from a kind 6 or kind 16 repost's content.
* Returns undefined if the content is empty or not valid JSON.
*/
export function parseRepostContent(repost: NostrEvent): NostrEvent | undefined {
if (!repost.content || repost.content.trim() === '') return undefined;
try {
const parsed = JSON.parse(repost.content);
if (parsed && typeof parsed === 'object' && parsed.id && parsed.pubkey && parsed.kind !== undefined) {
return parsed as NostrEvent;
}
} catch {
// invalid JSON
}
return undefined;
}