Compare commits

...

1 Commits

Author SHA1 Message Date
Derek Ross 0ff018eb1b Fix duplicate articles in My Articles tab
- Filter out deleted articles (NIP-09) by querying kind 5 events and
  checking both e-tag and a-tag coordinates
- Deduplicate addressable events by d-tag, keeping newest version
- Auto-delete old addressable event when slug changes during edit
- Upgrade shared deduplicateEvents utility to be protocol-aware
  (addressable by pubkey:kind:d-tag, replaceable by pubkey:kind)
- Invalidate published-articles cache on event deletion
2026-04-02 14:41:59 -04:00
4 changed files with 137 additions and 14 deletions
+29 -1
View File
@@ -334,6 +334,8 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
const doPublish = useCallback(() => {
if (!user) return;
const newSlug = article.slug || slugify(article.title, { lower: true, strict: true });
// Use original published_at when editing, current time for new articles
const publishedAtTimestamp =
isEditMode && originalPublishedAt
@@ -341,7 +343,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
: Math.floor(Date.now() / 1000);
const tags: string[][] = [
['d', article.slug || slugify(article.title, { lower: true, strict: true })],
['d', newSlug],
['title', article.title],
['published_at', publishedAtTimestamp.toString()],
];
@@ -369,6 +371,30 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
setIsPublished(true);
setHasUnsavedChanges(false);
// If the slug changed during edit, delete the old addressable event
// so it doesn't appear as a duplicate in My Articles.
if (isEditMode && originalSlug && originalSlug !== newSlug) {
try {
// Query the old event to get its ID for a thorough deletion
const oldEvents = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [originalSlug], limit: 1 },
]);
const deletionTags: string[][] = [
['a', `30023:${user.pubkey}:${originalSlug}`],
['k', '30023'],
];
// Include the e-tag if we found the old event
if (oldEvents.length > 0) {
deletionTags.unshift(['e', oldEvents[0].id]);
}
publishEvent({ kind: 5, content: '', tags: deletionTags });
} catch (error) {
console.error('Failed to delete old article slug:', error);
}
}
// Remove draft after publishing
if (article.slug) {
deleteDraftBySlug(article.slug);
@@ -402,9 +428,11 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
}, [
user,
article,
nostr,
publishEvent,
deleteRelayDraft,
isEditMode,
originalSlug,
originalPublishedAt,
navigate,
]);
+1
View File
@@ -63,6 +63,7 @@ export function useDeleteEvent() {
queryClient.invalidateQueries({ queryKey: ['profile-likes-infinite'] });
queryClient.invalidateQueries({ queryKey: ['replies'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
queryClient.invalidateQueries({ queryKey: ['published-articles'] });
},
});
}
+53 -6
View File
@@ -22,6 +22,22 @@ function eventToArticle(event: NostrEvent): PublishedArticle {
};
}
/**
* Collect deleted event IDs and addressable-event coordinates from
* kind 5 deletion request events (NIP-09).
*/
function getDeletedTargets(deletionEvents: NostrEvent[]): { ids: Set<string>; coords: Set<string> } {
const ids = new Set<string>();
const coords = new Set<string>();
for (const event of deletionEvents) {
for (const [name, value] of event.tags) {
if (name === 'e' && value) ids.add(value);
if (name === 'a' && value) coords.add(value);
}
}
return { ids, coords };
}
export function usePublishedArticles() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -33,13 +49,44 @@ export function usePublishedArticles() {
return [];
}
const events = await nostr.query(
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
const timeout = AbortSignal.timeout(5000);
return events
.filter(e => e.content.trim().length > 0)
// Fetch articles and their deletion events in parallel.
const [events, deletions] = await Promise.all([
nostr.query(
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, timeout]) },
),
nostr.query(
[{ kinds: [5], authors: [user.pubkey], '#k': ['30023'], limit: 500 }],
{ signal: AbortSignal.any([signal, timeout]) },
),
]);
const { ids: deletedIds, coords: deletedCoords } = getDeletedTargets(deletions);
// Deduplicate addressable events by d-tag, keeping the newest version.
// Multiple relays may return different versions of the same article.
const latestByDTag = new Map<string, NostrEvent>();
for (const event of events) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
const existing = latestByDTag.get(dTag);
if (!existing || event.created_at > existing.created_at) {
latestByDTag.set(dTag, event);
}
}
return Array.from(latestByDTag.values())
.filter(e => {
// Skip empty articles
if (!e.content.trim()) return false;
// Skip articles deleted by event ID
if (deletedIds.has(e.id)) return false;
// Skip articles deleted by addressable coordinate (a-tag)
const dTag = e.tags.find(([name]) => name === 'd')?.[1] ?? '';
if (deletedCoords.has(`30023:${user.pubkey}:${dTag}`)) return false;
return true;
})
.map(eventToArticle)
.sort((a, b) => b.publishedAt - a.publishedAt);
},
+54 -7
View File
@@ -1,17 +1,64 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Whether a kind falls in the addressable (parameterized-replaceable) range. */
function isAddressable(kind: number): boolean {
return kind >= 30000 && kind < 40000;
}
/** Whether a kind falls in the replaceable range. */
function isReplaceable(kind: number): boolean {
return kind >= 10000 && kind < 20000;
}
/**
* Flatten paginated Nostr event arrays and deduplicate by event ID.
* Build a deduplication key for a Nostr event.
*
* - Addressable events (kind 30000-39999): `pubkey:kind:d-tag` — only the
* latest version per coordinate should be kept.
* - Replaceable events (kind 10000-19999): `pubkey:kind` — only the latest
* version per pubkey+kind should be kept.
* - Regular events: the event `id` (unique by definition).
*/
function dedupeKey(event: NostrEvent): string {
if (isAddressable(event.kind)) {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
return `${event.pubkey}:${event.kind}:${dTag}`;
}
if (isReplaceable(event.kind)) {
return `${event.pubkey}:${event.kind}`;
}
return event.id;
}
/**
* Flatten paginated Nostr event arrays and deduplicate.
*
* - Regular events are deduplicated by event ID.
* - Addressable events (kind 30000-39999) are deduplicated by
* `pubkey+kind+d-tag`, keeping the newest version.
* - Replaceable events (kind 10000-19999) are deduplicated by
* `pubkey+kind`, keeping the newest version.
*
* Accepts the `pages` property from a TanStack `useInfiniteQuery` result
* where each page is a `NostrEvent[]`.
*/
export function deduplicateEvents(pages: NostrEvent[][] | undefined): NostrEvent[] {
if (!pages) return [];
const seen = new Set<string>();
return pages.flat().filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
return true;
});
const best = new Map<string, NostrEvent>();
for (const event of pages.flat()) {
const key = dedupeKey(event);
const existing = best.get(key);
if (!existing) {
best.set(key, event);
} else if (key === event.id) {
// Regular event — same id means same event, skip.
} else if (event.created_at > existing.created_at) {
// Replaceable / addressable — keep the newer version.
best.set(key, event);
}
}
return Array.from(best.values());
}