mirror of
https://github.com/layer-systems/website.git
synced 2026-09-15 07:47:34 +02:00
* 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>
118 lines
4.4 KiB
TypeScript
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');
|
|
}
|