Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ff018eb1b |
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user