diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index cce9f322..db956c44 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -9,6 +9,7 @@ import { DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_UPVOTE_DISPLAY } from '@/lib/discussion-votes' +import { getWebBookmarkArticleUrl } from '@/lib/rss-article' import { cn } from '@/lib/utils' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useMuteListOptional } from '@/contexts/mute-list-context' @@ -156,6 +157,13 @@ export default function ContentPreview({ return withKindRow() } + if (event.kind === ExtendedKind.WEB_BOOKMARK) { + const href = getWebBookmarkArticleUrl(event) + const title = event.tags.find((t) => t[0] === 'title')?.[1]?.trim() + const line = title?.trim() || href?.trim() || t('Web bookmark') + return withKindRow(
{line}
) + } + if (event.kind === ExtendedKind.POLL) { if (forParentReplyBlurb) { const snippet = parentReplyPollQuestionBlurb(event.content ?? '') diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 8e461453..d7befb1e 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -29,7 +29,11 @@ import type { HighlightData } from '@/components/PostEditor/HighlightEditor' import { Event, kinds } from 'nostr-tools' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { getWebExternalReactionTargetUrl, isRssThreadSyntheticParentEvent } from '@/lib/rss-article' +import { + getWebBookmarkArticleUrl, + getWebExternalReactionTargetUrl, + isRssThreadSyntheticParentEvent +} from '@/lib/rss-article' import { CreateHighlightContext } from './CreateHighlightContext' import SelectionHighlightTrigger from './SelectionHighlightTrigger' import AudioPlayer from '../AudioPlayer' @@ -181,6 +185,32 @@ export default function Note({
Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}
} + } else if (event.kind === ExtendedKind.WEB_BOOKMARK) { + const href = getWebBookmarkArticleUrl(event) + const title = event.tags.find((tag) => tag[0] === 'title')?.[1]?.trim() + content = ( + <> + {title ? ( +

{title}

+ ) : null} + {href ? ( +
+ + {href} + + +
+ ) : null} + {event.content?.trim() ? ( +

{event.content}

+ ) : null} + + ) } else if (event.kind === ExtendedKind.WIKI_ARTICLE) { content = showFull ? ( diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 382eda73..9bfbdfd8 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -81,7 +81,7 @@ export default function NoteStats({ {!isRssArticleRoot && !isZapPoll && ( )} - + {!isRssArticleRoot && } @@ -109,7 +109,7 @@ export default function NoteStats({ )}
- + {!isRssArticleRoot && }
diff --git a/src/components/RssArticleWebBookmarks/index.tsx b/src/components/RssArticleWebBookmarks/index.tsx new file mode 100644 index 00000000..8a49333d --- /dev/null +++ b/src/components/RssArticleWebBookmarks/index.tsx @@ -0,0 +1,215 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { Textarea } from '@/components/ui/textarea' +import { ExtendedKind } from '@/constants' +import { createWebBookmarkDraftEvent } from '@/lib/draft-event' +import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' +import logger from '@/lib/logger' +import { showPublishingError } from '@/lib/publishing-feedback' +import { + canonicalizeRssArticleUrl, + createRssThreadRootEvent, + expandArticleUrlThreadQueryValues, + getWebBookmarkArticleUrl +} from '@/lib/rss-article' +import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' +import client from '@/services/client.service' +import noteStatsService from '@/services/note-stats.service' +import { Trash2 } from 'lucide-react' +import type { Event } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * NIP-B0 (kind 39701) web bookmarks for the current article URL: list, add, and remove (replaceable tombstone). + * Shown under URL cards on {@link RssArticlePage}, separate from NIP-51 bookmark lists. + */ +export default function RssArticleWebBookmarks({ articleUrl }: { articleUrl: string }) { + const { t } = useTranslation() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { pubkey, publish, attemptDelete, relayList, account } = useNostr() + + const canonical = useMemo(() => canonicalizeRssArticleUrl(articleUrl), [articleUrl]) + const iVals = useMemo(() => { + const v = expandArticleUrlThreadQueryValues(canonical) + return v.length > 0 ? v : [canonical] + }, [canonical]) + + const relayUrls = useMemo(() => { + const read = relayList?.read ?? [] + const base = getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, read, {}) + if (!base.length) return [] + return appendCuratedReadOnlyRelays(base, blockedRelays) + }, [favoriteRelays, blockedRelays, relayList?.read]) + + const [mine, setMine] = useState([]) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [title, setTitle] = useState('') + const [note, setNote] = useState('') + + const reload = useCallback(async () => { + if (!pubkey || !relayUrls.length) { + setMine([]) + return + } + setLoading(true) + try { + const filters = [ + { authors: [pubkey], kinds: [ExtendedKind.WEB_BOOKMARK], '#i': iVals, limit: 40 }, + { authors: [pubkey], kinds: [ExtendedKind.WEB_BOOKMARK], '#I': iVals, limit: 40 } + ] + const batches = await Promise.all( + filters.map((f) => client.fetchEvents(relayUrls, f, { cache: false }).catch(() => [] as Event[])) + ) + const byKey = new Map() + for (const ev of batches.flat()) { + if (ev.pubkey !== pubkey) continue + const u = getWebBookmarkArticleUrl(ev) + if (!u || canonicalizeRssArticleUrl(u) !== canonical) continue + const d = ev.tags.find((t) => t[0] === 'd')?.[1] + const key = d ? `wb:${pubkey}:${d}` : ev.id + const prev = byKey.get(key) + if (!prev || ev.created_at > prev.created_at) byKey.set(key, ev) + } + setMine([...byKey.values()].sort((a, b) => b.created_at - a.created_at)) + } catch (e) { + logger.warn('[RssArticleWebBookmarks] fetch failed', e) + setMine([]) + } finally { + setLoading(false) + } + }, [pubkey, relayUrls, iVals, canonical]) + + useEffect(() => { + void reload() + }, [reload]) + + const rssRootId = useMemo(() => createRssThreadRootEvent(articleUrl).id, [articleUrl]) + + const onSave = async () => { + if (!pubkey || account?.signerType === 'npub') { + showPublishingError(new Error(t('Sign in to publish web bookmark'))) + return + } + setSaving(true) + try { + const draft = createWebBookmarkDraftEvent({ + url: articleUrl, + title: title.trim() || undefined, + note: note.trim() || undefined + }) + const ev = await publish(draft) + setTitle('') + setNote('') + await reload() + noteStatsService.updateNoteStatsByEvents([ev], undefined, { + interactionTargetNoteId: rssRootId + }) + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } finally { + setSaving(false) + } + } + + const onRemove = async (ev: Event) => { + try { + await attemptDelete(ev) + await reload() + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } + } + + if (!pubkey) { + return ( +
+ {t('Log in to save web bookmarks')} +
+ ) + } + + return ( +
+
+

{t('Web bookmarks')}

+ {loading ? {t('Loading...')} : null} +
+

+ {t('Web bookmarks NIP intro')} +

+ + {mine.length > 0 ? ( +
    + {mine.map((ev) => { + const label = + ev.tags.find((t) => t[0] === 'title')?.[1]?.trim() || getWebBookmarkArticleUrl(ev) || t('Web bookmark') + return ( +
  • t[0] === 'd')?.[1] ?? ev.id}`} + className="flex items-start justify-between gap-2 rounded-md border border-border/60 bg-background/50 px-2 py-1.5 text-sm" + > + {label} + +
  • + ) + })} +
+ ) : !loading ? ( +

{t('No web bookmark for this URL yet')}

+ ) : null} + + + +
+
+ + setTitle(e.target.value)} + placeholder={t('Page title')} + className="h-9" + /> +
+
+ +