13 changed files with 469 additions and 94 deletions
@ -0,0 +1,87 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
collapseStaleAddressableRevisions, |
||||||
|
compareReplaceableRevision, |
||||||
|
dedupeLatestAddressableEvents, |
||||||
|
getAddressableDedupeKey, |
||||||
|
pickNewestReplaceableRevision, |
||||||
|
upsertEventMapPreferNewestAddressable |
||||||
|
} from '@/lib/replaceable-revision' |
||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Event { |
||||||
|
return { |
||||||
|
id: partial.id ?? 'a'.repeat(64), |
||||||
|
pubkey: partial.pubkey ?? 'b'.repeat(64), |
||||||
|
content: partial.content ?? '', |
||||||
|
created_at: partial.created_at ?? 1, |
||||||
|
sig: 'sig', |
||||||
|
...partial |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('replaceable revision helpers', () => { |
||||||
|
it('builds addressable dedupe keys from d tags', () => { |
||||||
|
const ev = fakeEvent({ |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
tags: [['d', 'example.com/path']] |
||||||
|
}) |
||||||
|
expect(getAddressableDedupeKey(ev)).toBe(`${ev.pubkey}:${ExtendedKind.WEB_BOOKMARK}:example.com/path`) |
||||||
|
}) |
||||||
|
|
||||||
|
it('picks the newest revision by created_at then id', () => { |
||||||
|
const older = fakeEvent({ |
||||||
|
id: '1'.repeat(64), |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
created_at: 10, |
||||||
|
tags: [['d', 'example.com']] |
||||||
|
}) |
||||||
|
const newer = fakeEvent({ |
||||||
|
id: '2'.repeat(64), |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
created_at: 20, |
||||||
|
tags: [['d', 'example.com']] |
||||||
|
}) |
||||||
|
expect(pickNewestReplaceableRevision([older, newer])).toBe(newer) |
||||||
|
expect(compareReplaceableRevision(newer, older)).toBeGreaterThan(0) |
||||||
|
}) |
||||||
|
|
||||||
|
it('collapses stale addressable revisions in a feed batch', () => { |
||||||
|
const kind1 = fakeEvent({ kind: 1, created_at: 99, tags: [], content: 'note' }) |
||||||
|
const older = fakeEvent({ |
||||||
|
id: '1'.repeat(64), |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
created_at: 10, |
||||||
|
tags: [['d', 'example.com']] |
||||||
|
}) |
||||||
|
const newer = fakeEvent({ |
||||||
|
id: '2'.repeat(64), |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
created_at: 20, |
||||||
|
tags: [['d', 'example.com']] |
||||||
|
}) |
||||||
|
const out = collapseStaleAddressableRevisions([kind1, older, newer]) |
||||||
|
expect(out).toEqual([kind1, newer]) |
||||||
|
expect(dedupeLatestAddressableEvents([older, newer])).toEqual([newer]) |
||||||
|
}) |
||||||
|
|
||||||
|
it('supersedes older addressable rows in a by-id map', () => { |
||||||
|
const byId = new Map<string, Event>() |
||||||
|
const older = fakeEvent({ |
||||||
|
id: '1'.repeat(64), |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
created_at: 10, |
||||||
|
tags: [['d', 'example.com']] |
||||||
|
}) |
||||||
|
const newer = fakeEvent({ |
||||||
|
id: '2'.repeat(64), |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
created_at: 20, |
||||||
|
tags: [['d', 'example.com']] |
||||||
|
}) |
||||||
|
upsertEventMapPreferNewestAddressable(byId, older) |
||||||
|
upsertEventMapPreferNewestAddressable(byId, newer) |
||||||
|
expect([...byId.values()]).toEqual([newer]) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
/** NIP-33 addressable coordinate key (`pubkey:kind:d`) when `d` is present. */ |
||||||
|
export function getAddressableDedupeKey(event: Pick<Event, 'kind' | 'pubkey' | 'tags'>): string | null { |
||||||
|
if (event.kind < 30000 || event.kind >= 40000) return null |
||||||
|
const d = event.tags.find((t) => t[0] === 'd')?.[1]?.trim() |
||||||
|
if (!d) return null |
||||||
|
return `${event.pubkey}:${event.kind}:${d}` |
||||||
|
} |
||||||
|
|
||||||
|
/** Positive when `a` is a newer replaceable revision than `b`. */ |
||||||
|
export function compareReplaceableRevision(a: Event, b: Event): number { |
||||||
|
if (a.created_at !== b.created_at) return a.created_at - b.created_at |
||||||
|
return a.id.localeCompare(b.id) |
||||||
|
} |
||||||
|
|
||||||
|
export function pickNewestReplaceableRevision(candidates: readonly Event[]): Event | undefined { |
||||||
|
if (!candidates.length) return undefined |
||||||
|
return candidates.reduce((best, e) => (compareReplaceableRevision(e, best) > 0 ? e : best)) |
||||||
|
} |
||||||
|
|
||||||
|
/** One row per `pubkey:kind:d`; keeps the newest revision only. */ |
||||||
|
export function dedupeLatestAddressableEvents(events: readonly Event[]): Event[] { |
||||||
|
const latestByKey = new Map<string, Event>() |
||||||
|
const nonAddressable: Event[] = [] |
||||||
|
for (const evt of events) { |
||||||
|
const key = getAddressableDedupeKey(evt) |
||||||
|
if (!key) { |
||||||
|
nonAddressable.push(evt) |
||||||
|
continue |
||||||
|
} |
||||||
|
const existing = latestByKey.get(key) |
||||||
|
if (!existing || compareReplaceableRevision(evt, existing) > 0) { |
||||||
|
latestByKey.set(key, evt) |
||||||
|
} |
||||||
|
} |
||||||
|
return [...nonAddressable, ...latestByKey.values()] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Remove superseded addressable revisions from a timeline batch (feeds, thread replies). |
||||||
|
* Non-addressable rows are unchanged. |
||||||
|
*/ |
||||||
|
export function collapseStaleAddressableRevisions(events: readonly Event[]): Event[] { |
||||||
|
const latestByKey = new Map<string, Event>() |
||||||
|
for (const evt of events) { |
||||||
|
const key = getAddressableDedupeKey(evt) |
||||||
|
if (!key) continue |
||||||
|
const existing = latestByKey.get(key) |
||||||
|
if (!existing || compareReplaceableRevision(evt, existing) > 0) { |
||||||
|
latestByKey.set(key, evt) |
||||||
|
} |
||||||
|
} |
||||||
|
if (latestByKey.size === 0) return [...events] |
||||||
|
|
||||||
|
const winningIds = new Set<string>() |
||||||
|
for (const winner of latestByKey.values()) { |
||||||
|
winningIds.add(winner.id) |
||||||
|
} |
||||||
|
return events.filter((evt) => { |
||||||
|
const key = getAddressableDedupeKey(evt) |
||||||
|
if (!key) return true |
||||||
|
return winningIds.has(evt.id) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
/** When merging into a by-id map, supersede older addressable revisions (same `pubkey:kind:d`). */ |
||||||
|
export function upsertEventMapPreferNewestAddressable(byId: Map<string, Event>, evt: Event): void { |
||||||
|
const key = getAddressableDedupeKey(evt) |
||||||
|
if (!key) { |
||||||
|
byId.set(evt.id, evt) |
||||||
|
return |
||||||
|
} |
||||||
|
for (const [id, existing] of byId) { |
||||||
|
if (getAddressableDedupeKey(existing) !== key) continue |
||||||
|
if (compareReplaceableRevision(evt, existing) > 0) { |
||||||
|
byId.delete(id) |
||||||
|
byId.set(evt.id, evt) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
byId.set(evt.id, evt) |
||||||
|
} |
||||||
@ -0,0 +1,86 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
buildRssArticleUrlThreadInteractionFilterGroups, |
||||||
|
isRssArticleUrlThreadInteraction, |
||||||
|
isRssUrlThreadAntwortenTailKind |
||||||
|
} from '@/lib/rss-web-feed' |
||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { kinds, type Event } from 'nostr-tools' |
||||||
|
|
||||||
|
function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Event { |
||||||
|
return { |
||||||
|
id: 'a'.repeat(64), |
||||||
|
pubkey: 'b'.repeat(64), |
||||||
|
created_at: 1, |
||||||
|
content: '', |
||||||
|
sig: 'sig', |
||||||
|
...partial |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('RSS URL thread responses', () => { |
||||||
|
const url = 'https://github.com/nostr-protocol/nips/blob/master/B0.md' |
||||||
|
|
||||||
|
it('matches comments, highlights, web bookmarks, and page reactions', () => { |
||||||
|
expect( |
||||||
|
isRssArticleUrlThreadInteraction( |
||||||
|
fakeEvent({ |
||||||
|
kind: ExtendedKind.COMMENT, |
||||||
|
tags: [['i', url]] |
||||||
|
}), |
||||||
|
url |
||||||
|
) |
||||||
|
).toBe(true) |
||||||
|
expect( |
||||||
|
isRssArticleUrlThreadInteraction( |
||||||
|
fakeEvent({ |
||||||
|
kind: kinds.Highlights, |
||||||
|
tags: [['r', url]] |
||||||
|
}), |
||||||
|
url |
||||||
|
) |
||||||
|
).toBe(true) |
||||||
|
expect( |
||||||
|
isRssArticleUrlThreadInteraction( |
||||||
|
fakeEvent({ |
||||||
|
kind: ExtendedKind.WEB_BOOKMARK, |
||||||
|
tags: [['d', 'github.com/nostr-protocol/nips/blob/master/B0.md']] |
||||||
|
}), |
||||||
|
url |
||||||
|
) |
||||||
|
).toBe(true) |
||||||
|
expect( |
||||||
|
isRssArticleUrlThreadInteraction( |
||||||
|
fakeEvent({ |
||||||
|
kind: kinds.Reaction, |
||||||
|
tags: [['r', url], ['e', 'c'.repeat(64)]] |
||||||
|
}), |
||||||
|
url |
||||||
|
) |
||||||
|
).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('rejects unrelated kinds on the same URL scope', () => { |
||||||
|
expect( |
||||||
|
isRssArticleUrlThreadInteraction( |
||||||
|
fakeEvent({ kind: ExtendedKind.EXTERNAL_REACTION, tags: [['i', url]] }), |
||||||
|
url |
||||||
|
) |
||||||
|
).toBe(false) |
||||||
|
}) |
||||||
|
|
||||||
|
it('requests web bookmarks by d-tag and legacy i/I tags', () => { |
||||||
|
const { nonSocial } = buildRssArticleUrlThreadInteractionFilterGroups(url, 20) |
||||||
|
expect(nonSocial.some((f) => f.kinds?.includes(ExtendedKind.WEB_BOOKMARK) && f['#d'])).toBe(true) |
||||||
|
expect(nonSocial.some((f) => f.kinds?.includes(ExtendedKind.WEB_BOOKMARK) && f['#i'])).toBe(true) |
||||||
|
expect(nonSocial.some((f) => f.kinds?.includes(kinds.Reaction) && f['#r'])).toBe(true) |
||||||
|
expect(nonSocial.some((f) => f.kinds?.includes(kinds.Highlights) && f['#r'])).toBe(true) |
||||||
|
}) |
||||||
|
|
||||||
|
it('classifies tail kinds for URL thread layout', () => { |
||||||
|
expect(isRssUrlThreadAntwortenTailKind(kinds.Highlights)).toBe(true) |
||||||
|
expect(isRssUrlThreadAntwortenTailKind(ExtendedKind.WEB_BOOKMARK)).toBe(true) |
||||||
|
expect(isRssUrlThreadAntwortenTailKind(kinds.Reaction)).toBe(true) |
||||||
|
expect(isRssUrlThreadAntwortenTailKind(ExtendedKind.COMMENT)).toBe(false) |
||||||
|
}) |
||||||
|
}) |
||||||
Loading…
Reference in new issue