diff --git a/src/components/author/AuthorArticlesFeed.tsx b/src/components/author/AuthorArticlesFeed.tsx index 3489753..6809aa1 100644 --- a/src/components/author/AuthorArticlesFeed.tsx +++ b/src/components/author/AuthorArticlesFeed.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInView } from "react-intersection-observer"; import { MoreHorizontalIcon, @@ -12,11 +12,12 @@ import { PencilIcon, HeartIcon, MessageCircleIcon, + PinIcon, } from "lucide-react"; import { nip19 } from "nostr-tools"; import { useSession } from "next-auth/react"; -import { fetchBlogs } from "@/lib/nostr/fetch"; -import { broadcastEvent } from "@/lib/nostr/publish"; +import { fetchBlogs, fetchPinnedArticles, fetchBlogByAddress } from "@/lib/nostr/fetch"; +import { broadcastEvent, publishPinnedArticles } from "@/lib/nostr/publish"; import { useSettingsStore } from "@/lib/stores/settingsStore"; import { useProfile } from "@/lib/hooks/useProfiles"; import { useInteractionCounts } from "@/lib/hooks/useInteractionCounts"; @@ -80,6 +81,7 @@ export default function AuthorArticlesFeed({ npub }: AuthorArticlesFeedProps) { const [broadcastingBlogId, setBroadcastingBlogId] = useState( null, ); + const [isPinning, setIsPinning] = useState(false); const [isJsonOpen, setIsJsonOpen] = useState(false); const [jsonEvent, setJsonEvent] = useState(null); const [isEditProfileOpen, setIsEditProfileOpen] = useState(false); @@ -120,10 +122,47 @@ export default function AuthorArticlesFeed({ npub }: AuthorArticlesFeedProps) { enabled: isHydrated && !!activeRelay && !!pubkey, }); + const { data: pinnedData, refetch: refetchPinnedData } = useQuery({ + queryKey: ["pinned-articles", activeRelay, pubkey], + queryFn: () => + fetchPinnedArticles({ + pubkey: pubkey!, + relay: activeRelay, + }), + enabled: isHydrated && !!activeRelay && !!pubkey, + }); + + const firstPinnedItem = pinnedData?.pinnedArticles?.[0]; + const { data: pinnedBlog } = useQuery({ + queryKey: [ + "pinned-blog", + activeRelay, + firstPinnedItem?.pubkey, + firstPinnedItem?.identifier, + ], + queryFn: () => + fetchBlogByAddress({ + pubkey: firstPinnedItem!.pubkey, + identifier: firstPinnedItem!.identifier, + relay: firstPinnedItem?.relay || activeRelay, + }), + enabled: + isHydrated && + !!activeRelay && + !!firstPinnedItem?.pubkey && + !!firstPinnedItem?.identifier, + }); + const blogs = data?.pages.flatMap((page) => page.blogs) ?? []; - const countEventIds = blogs - .filter((blog) => blog.likeCount === undefined || blog.replyCount === undefined) - .map((blog) => blog.id); + const filteredBlogs = pinnedBlog + ? blogs.filter((blog) => blog.id !== pinnedBlog.id) + : blogs; + const countEventIds = [ + ...(pinnedBlog ? [pinnedBlog.id] : []), + ...filteredBlogs + .filter((blog) => blog.likeCount === undefined || blog.replyCount === undefined) + .map((blog) => blog.id), + ]; const { getCounts, isLoading: isInteractionCountLoading } = useInteractionCounts(countEventIds); const { data: profile, isLoading: isLoadingProfile } = useProfile(pubkey); @@ -173,6 +212,88 @@ export default function AuthorArticlesFeed({ npub }: AuthorArticlesFeedProps) { setIsJsonOpen(true); }; + const handlePinArticle = async (blog: Blog) => { + if (isPinning) return; + + if (!isOwnProfile) { + toast.error("You can only pin articles on your own profile"); + return; + } + + setIsPinning(true); + try { + const pinnedArticles = [ + { + kind: 30023, + pubkey: blog.pubkey, + identifier: blog.dTag, + relay: activeRelay, + }, + ]; + + const results = await publishPinnedArticles({ + pinnedArticles, + relays, + secretKey: viewer?.secretKey, + }); + + const successfulRelays = results.filter((r) => r.success); + const successCount = successfulRelays.length; + + if (successCount > 0) { + toast.success("Article pinned!", { + description: `Pinned to ${successCount} relay${successCount !== 1 ? "s" : ""}`, + }); + refetchPinnedData(); + } else { + toast.error("Failed to pin article", { + description: "Failed to pin to any relay", + }); + } + } catch (err) { + console.error("Failed to pin article:", err); + toast.error("Failed to pin article", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsPinning(false); + } + }; + + const handleUnpinArticle = async () => { + if (isPinning || !isOwnProfile) return; + + setIsPinning(true); + try { + const results = await publishPinnedArticles({ + pinnedArticles: [], + relays, + secretKey: viewer?.secretKey, + }); + + const successfulRelays = results.filter((r) => r.success); + const successCount = successfulRelays.length; + + if (successCount > 0) { + toast.success("Article unpinned!", { + description: `Updated ${successCount} relay${successCount !== 1 ? "s" : ""}`, + }); + refetchPinnedData(); + } else { + toast.error("Failed to unpin article", { + description: "Failed to update any relay", + }); + } + } catch (err) { + console.error("Failed to unpin article:", err); + toast.error("Failed to unpin article", { + description: err instanceof Error ? err.message : "Unknown error", + }); + } finally { + setIsPinning(false); + } + }; + return (
@@ -241,14 +362,155 @@ export default function AuthorArticlesFeed({ npub }: AuthorArticlesFeedProps) {
)} - {pubkey && !isLoading && !isError && blogs.length === 0 && ( + {pubkey && !isLoading && !isError && blogs.length === 0 && !pinnedBlog && (
No articles found
)} + {pinnedBlog && ( +
+
+ + Pinned article +
+ {(() => { + const thumbnail = pinnedBlog.image || extractFirstImage(pinnedBlog.content); + const readMinutes = estimateReadTime( + pinnedBlog.content || pinnedBlog.summary || "", + ); + const naddr = blogToNaddr(pinnedBlog, relays); + const interaction = getCounts(pinnedBlog.id); + const likeCount = interaction?.likeCount ?? pinnedBlog.likeCount; + const replyCount = interaction?.replyCount ?? pinnedBlog.replyCount; + const isCountLoading = + isInteractionCountLoading(pinnedBlog.id) && + likeCount === undefined && + replyCount === undefined; + + return ( +
+
+ + {formatDate(pinnedBlog.publishedAt || pinnedBlog.createdAt)} + + + + + + + + + {isOwnProfile && ( + <> + { + e.preventDefault(); + e.stopPropagation(); + handleUnpinArticle(); + }} + disabled={isPinning} + > + + {isPinning ? "Unpinning..." : "Unpin article"} + + + + )} + { + e.preventDefault(); + e.stopPropagation(); + downloadMarkdownFile(pinnedBlog.title, pinnedBlog.content || ""); + }} + > + + Download markdown + + { + e.preventDefault(); + handleBroadcast(pinnedBlog, e); + }} + disabled={ + broadcastingBlogId === pinnedBlog.id || !pinnedBlog.rawEvent + } + > + + {broadcastingBlogId === pinnedBlog.id + ? "Broadcasting..." + : "Broadcast"} + + { + e.preventDefault(); + e.stopPropagation(); + handleViewJson(pinnedBlog.rawEvent); + }} + disabled={!pinnedBlog.rawEvent} + > + + View raw JSON + + + +
+ + +
+

+ {pinnedBlog.title || "Untitled"} +

+

+ {pinnedBlog.summary} +

+
+ {readMinutes} min read + + + + + + + + + + +
+
+
+ {thumbnail ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + +
+ ); + })()} +
+ )} +
    - {blogs.map((blog) => { + {filteredBlogs.map((blog) => { const thumbnail = blog.image || extractFirstImage(blog.content); const readMinutes = estimateReadTime( blog.content || blog.summary || "", @@ -284,6 +546,36 @@ export default function AuthorArticlesFeed({ npub }: AuthorArticlesFeedProps) { + {isOwnProfile && ( + <> + {pinnedBlog?.id === blog.id ? ( + { + e.preventDefault(); + e.stopPropagation(); + handleUnpinArticle(); + }} + disabled={isPinning} + > + + {isPinning ? "Unpinning..." : "Unpin article"} + + ) : ( + { + e.preventDefault(); + e.stopPropagation(); + handlePinArticle(blog); + }} + disabled={isPinning} + > + + {isPinning ? "Pinning..." : "Pin article"} + + )} + + + )} { e.preventDefault(); diff --git a/src/lib/nostr/fetch.ts b/src/lib/nostr/fetch.ts index 1bd4444..f539d46 100644 --- a/src/lib/nostr/fetch.ts +++ b/src/lib/nostr/fetch.ts @@ -1,4 +1,4 @@ -import { NostrEvent, Blog, eventToBlog, Highlight, StackItem } from './types'; +import { NostrEvent, Blog, eventToBlog, Highlight, StackItem, PinnedArticles, eventToPinnedArticles } from './types'; interface FetchBlogsOptions { limit?: number; @@ -893,3 +893,65 @@ export async function fetchHighlights({ }; }); } + +export async function fetchPinnedArticles({ + pubkey, + relay, +}: { + pubkey: string; + relay: string; +}): Promise { + return new Promise((resolve) => { + const ws = new WebSocket(relay); + const subId = `pinned-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + let timeoutId: NodeJS.Timeout; + let latestEvent: NostrEvent | null = null; + + ws.onopen = () => { + ws.send( + JSON.stringify([ + 'REQ', + subId, + { + kinds: [10001], + authors: [pubkey], + limit: 1, + }, + ]), + ); + + timeoutId = setTimeout(() => { + ws.send(JSON.stringify(['CLOSE', subId])); + ws.close(); + resolve(latestEvent ? eventToPinnedArticles(latestEvent) : null); + }, 10000); + }; + + ws.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data); + + if (data[0] === 'EVENT' && data[1] === subId) { + const event = data[2] as NostrEvent; + if (event.kind === 10001) { + if (!latestEvent || event.created_at > latestEvent.created_at) { + latestEvent = event; + } + } + } else if (data[0] === 'EOSE' && data[1] === subId) { + clearTimeout(timeoutId); + ws.send(JSON.stringify(['CLOSE', subId])); + ws.close(); + resolve(latestEvent ? eventToPinnedArticles(latestEvent) : null); + } + } catch { + // Ignore parse errors + } + }; + + ws.onerror = () => { + clearTimeout(timeoutId); + resolve(null); + }; + }); +} diff --git a/src/lib/nostr/publish.ts b/src/lib/nostr/publish.ts index e2112e7..83d8903 100644 --- a/src/lib/nostr/publish.ts +++ b/src/lib/nostr/publish.ts @@ -500,3 +500,37 @@ async function publishToRelay(event: NostrEvent, relay: string): Promise; + relays: string[]; + secretKey?: string; +}): Promise { + const createdAt = Math.floor(Date.now() / 1000); + const eventTags: string[][] = pinnedArticles + .filter((item) => item.kind === 30023) + .map((item) => { + const aValue = `${item.kind}:${item.pubkey}:${item.identifier}`; + return item.relay ? ['a', aValue, item.relay] : ['a', aValue]; + }); + + const unsignedEvent = { + kind: 10001, + created_at: createdAt, + tags: eventTags, + content: '', + }; + + const signedEvent = await signEvent({ event: unsignedEvent, secretKey }); + + return Promise.all(relays.map((relay) => publishToRelay(signedEvent, relay))); +} diff --git a/src/lib/nostr/types.ts b/src/lib/nostr/types.ts index e5d4ed3..0a2f7b4 100644 --- a/src/lib/nostr/types.ts +++ b/src/lib/nostr/types.ts @@ -148,6 +148,43 @@ export function eventToStack(event: NostrEvent): Stack { }; } +export interface PinnedArticles { + pubkey: string; + createdAt: number; + pinnedArticles: StackItem[]; + rawEvent?: NostrEvent; +} + +export function eventToPinnedArticles(event: NostrEvent): PinnedArticles | null { + if (event.kind !== 10001) return null; + + const pinnedArticles: StackItem[] = event.tags + .filter((t) => t[0] === 'a') + .reduce((acc, t) => { + const parts = t[1]?.split(':'); + if (!parts || parts.length < 3) return acc; + const [kindStr, pubkey, identifier] = parts; + const kind = parseInt(kindStr, 10); + if (isNaN(kind) || !pubkey || !identifier) return acc; + if (kind === 30023) { + acc.push({ + kind, + pubkey, + identifier, + relay: t[2], + }); + } + return acc; + }, []); + + return { + pubkey: event.pubkey, + createdAt: event.created_at, + pinnedArticles, + rawEvent: event, + }; +} + // NIP-22 Comment (kind 1111) export interface Comment { id: string;