13 changed files with 469 additions and 94 deletions
@ -0,0 +1,87 @@
@@ -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 @@
@@ -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 @@
@@ -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