Files
website/src/lib/nostrUtils.ts
mroxso c180e0eb51 fix: exclude replies from the Feed and Profile timelines (#27)
* fix: exclude replies from the Feed and Profile timelines

Per NIP-10 a kind-1 event with an `e` tag is a reply, but the Feed and
Profile timelines rendered every kind-1 event with no such check, so
replies showed up indistinguishable from root posts. `isReply()` was
already written for this in nostrUtils but never used anywhere.

Fixes #20

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: don't treat mention-only e tags as replies

Per review: isReply() flagged any e tag as a reply, including one
marked "mention" — a citation, not a thread reply per NIP-10. That
would have hidden quote-notes from the Feed/Profile timelines they
belong in. Also fixed rootReference()'s docstring, which claimed to
fall back to the *last* positional e tag when the code (correctly)
uses the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

* fix: rootReference() no longer treats a mention as the root

Follow-up to the isReply() fix: rootReference()'s positional fallback
still matched any e tag regardless of marker, so an event with only a
mention-marked e tag would incorrectly return the mentioned id as the
thread root. The fallback now only considers unmarked e tags, per the
deprecated positional NIP-10 scheme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 18:37:13 +02:00

118 lines
4.4 KiB
TypeScript

import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
/** Protocols we are willing to put into an href or a src. */
const SAFE_PROTOCOLS = new Set(['https:', 'http:', 'mailto:', 'nostr:']);
/**
* Returns the URL only if it uses a protocol that cannot execute script.
* Everything reaching this function came from a relay, so `javascript:` and
* `data:` URLs must never survive it.
*/
export function sanitizeUrl(url: string | undefined): string | undefined {
if (!url) return undefined;
try {
const parsed = new URL(url, window.location.origin);
return SAFE_PROTOCOLS.has(parsed.protocol) ? parsed.href : undefined;
} catch {
return undefined;
}
}
/** A short, stable stand-in when someone has published no display name. */
export function genUserName(pubkey: string): string {
return `npub…${pubkey.slice(-6)}`;
}
export function displayName(pubkey: string, metadata?: NostrMetadata): string {
const name = metadata?.display_name?.trim() || metadata?.name?.trim();
return name || genUserName(pubkey);
}
export function npubOf(pubkey: string): string {
try {
return nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
}
const UNITS: [limit: number, divisor: number, unit: Intl.RelativeTimeFormatUnit][] = [
[60, 1, 'second'],
[3600, 60, 'minute'],
[86400, 3600, 'hour'],
[604800, 86400, 'day'],
[2629800, 604800, 'week'],
[31557600, 2629800, 'month'],
];
/** "3 min ago" style timestamps, localized by the browser. */
export function relativeTime(unixSeconds: number): string {
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const diff = unixSeconds - Math.floor(Date.now() / 1000);
const magnitude = Math.abs(diff);
for (const [limit, divisor, unit] of UNITS) {
if (magnitude < limit) {
return formatter.format(Math.round(diff / divisor), unit);
}
}
return formatter.format(Math.round(diff / 31557600), 'year');
}
export function absoluteTime(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toLocaleString();
}
/**
* Relay hints travel inside `nprofile`/`nevent`/`naddr` identifiers and are the
* only reason many deep links resolve at all: the event often lives on a relay
* the reader does not subscribe to. They are carried through window params as a
* comma-separated list.
*/
export function encodeRelayHints(relays: string[] | undefined): string | undefined {
const safe = (relays ?? []).filter((url) => url.startsWith('wss://') || url.startsWith('ws://'));
return safe.length > 0 ? safe.join(',') : undefined;
}
export function decodeRelayHints(value: string | undefined): string[] | undefined {
if (!value) return undefined;
const relays = value.split(',').filter((url) => url.startsWith('wss://') || url.startsWith('ws://'));
return relays.length > 0 ? relays : undefined;
}
/** First value of a tag, e.g. `tagValue(event, 'title')`. */
export function tagValue(event: NostrEvent, name: string): string | undefined {
return event.tags.find(([tagName]) => tagName === name)?.[1];
}
/** All values of a repeated tag. */
export function tagValues(event: NostrEvent, name: string): string[] {
return event.tags.filter(([tagName]) => tagName === name).map(([, value]) => value).filter(Boolean);
}
/**
* The event a reply points at, following NIP-10: prefer an explicit `root`
* marker, fall back to the first *unmarked* `e` tag (the deprecated scheme
* puts the root id first: `["e", <root-id>], ["e", <reply-id>]`). A `mention`
* or `reply`-only marker is never treated as the root — a mention isn't
* part of the thread, and a lone `reply` marker without `root` is malformed
* per NIP-10 rather than an implicit root.
*/
export function rootReference(event: NostrEvent): string | undefined {
const marked = event.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
if (marked) return marked[1];
const positional = event.tags.filter(([name, , , marker]) => name === 'e' && !marker);
return positional[0]?.[1];
}
/**
* True for a reply per NIP-10: a marked `root`/`reply` `e` tag, or an
* unmarked one (the deprecated positional scheme). An `e` tag marked
* `mention` alone does not make an event a reply — it cites another event
* without being part of its thread.
*/
export function isReply(event: NostrEvent): boolean {
return event.tags.some(([name, , , marker]) => name === 'e' && marker !== 'mention');
}