34 changed files with 845 additions and 72 deletions
@ -0,0 +1,215 @@
@@ -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<Event[]>([]) |
||||
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<string, Event>() |
||||
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 ( |
||||
<div className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground"> |
||||
{t('Log in to save web bookmarks')} |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/15 px-3 py-3"> |
||||
<div className="flex items-center justify-between gap-2"> |
||||
<h3 className="text-sm font-semibold">{t('Web bookmarks')}</h3> |
||||
{loading ? <span className="text-xs text-muted-foreground">{t('Loading...')}</span> : null} |
||||
</div> |
||||
<p className="text-xs text-muted-foreground"> |
||||
{t('Web bookmarks NIP intro')} |
||||
</p> |
||||
|
||||
{mine.length > 0 ? ( |
||||
<ul className="space-y-2"> |
||||
{mine.map((ev) => { |
||||
const label = |
||||
ev.tags.find((t) => t[0] === 'title')?.[1]?.trim() || getWebBookmarkArticleUrl(ev) || t('Web bookmark') |
||||
return ( |
||||
<li |
||||
key={`${ev.pubkey}:${ev.tags.find((t) => 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" |
||||
> |
||||
<span className="min-w-0 flex-1 break-words">{label}</span> |
||||
<Button |
||||
type="button" |
||||
variant="ghost" |
||||
size="icon" |
||||
className="size-8 shrink-0 text-muted-foreground hover:text-destructive" |
||||
title={t('Remove web bookmark')} |
||||
onClick={() => void onRemove(ev)} |
||||
> |
||||
<Trash2 className="size-4" /> |
||||
</Button> |
||||
</li> |
||||
) |
||||
})} |
||||
</ul> |
||||
) : !loading ? ( |
||||
<p className="text-xs text-muted-foreground">{t('No web bookmark for this URL yet')}</p> |
||||
) : null} |
||||
|
||||
<Separator /> |
||||
|
||||
<div className="space-y-2"> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="wb-title" className="text-xs text-muted-foreground"> |
||||
{t('Title')} ({t('optional')}) |
||||
</Label> |
||||
<Input |
||||
id="wb-title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
placeholder={t('Page title')} |
||||
className="h-9" |
||||
/> |
||||
</div> |
||||
<div className="space-y-1"> |
||||
<Label htmlFor="wb-note" className="text-xs text-muted-foreground"> |
||||
{t('Note')} ({t('optional')}) |
||||
</Label> |
||||
<Textarea |
||||
id="wb-note" |
||||
value={note} |
||||
onChange={(e) => setNote(e.target.value)} |
||||
placeholder={t('Short description')} |
||||
rows={2} |
||||
className="min-h-[4rem] resize-y text-sm" |
||||
/> |
||||
</div> |
||||
<Button |
||||
type="button" |
||||
size="sm" |
||||
disabled={saving || account?.signerType === 'npub'} |
||||
onClick={() => void onSave()} |
||||
> |
||||
{saving ? t('Publishing...') : t('Save web bookmark')} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
import { canonicalizeRssArticleUrl } from '@/lib/rss-article' |
||||
|
||||
/** |
||||
* NIP-B0: `d` tag is the URL without the scheme (`https://` / `http://` assumed). |
||||
*/ |
||||
export function urlToWebBookmarkDTag(url: string): string { |
||||
const t = url.trim() |
||||
if (!t) return '' |
||||
const withScheme = |
||||
t.startsWith('http://') || t.startsWith('https://') ? canonicalizeRssArticleUrl(t) : `https://${t}` |
||||
return withScheme.replace(/^https?:\/\//i, '') |
||||
} |
||||
@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
|
||||
import { RefreshButton } from '@/components/RefreshButton' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' |
||||
import { usePrimaryPage } from '@/contexts/primary-page-context' |
||||
import { cn } from '@/lib/utils' |
||||
import { |
||||
useSmartFollowingListNavigation, |
||||
useSmartMuteListNavigation, |
||||
useSmartSettingsNavigation |
||||
} from '@/PageManager' |
||||
import { toFollowSetsSettings, toFollowingList, toMuteList } from '@/lib/link' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { Bookmark, ChevronRight, Pin, Users, VolumeX } from 'lucide-react' |
||||
import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
/** |
||||
* Hub for Nostr “personal lists” (mute list, follows, NIP-51 bookmarks, pins) — not the same as NIP-B0 web bookmarks. |
||||
*/ |
||||
const PersonalListsSettingsPage = forwardRef( |
||||
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { |
||||
const { t } = useTranslation() |
||||
const { pubkey } = useNostr() |
||||
const { navigate: navigatePrimary } = usePrimaryPage() |
||||
const { navigateToSettings } = useSmartSettingsNavigation() |
||||
const { navigateToMuteList } = useSmartMuteListNavigation() |
||||
const { navigateToFollowingList } = useSmartFollowingListNavigation() |
||||
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() |
||||
const [contentKey, setContentKey] = useState(0) |
||||
const bump = useCallback(() => setContentKey((k) => k + 1), []) |
||||
|
||||
useEffect(() => { |
||||
if (!hideTitlebar) { |
||||
registerPrimaryPanelRefresh(null) |
||||
return |
||||
} |
||||
registerPrimaryPanelRefresh(bump) |
||||
return () => registerPrimaryPanelRefresh(null) |
||||
}, [hideTitlebar, registerPrimaryPanelRefresh, bump]) |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={hideTitlebar ? undefined : t('Personal Lists')} |
||||
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />} |
||||
> |
||||
<div key={contentKey} className="min-w-0 space-y-1 px-1 pt-2"> |
||||
<p className="px-3 pb-3 text-sm text-muted-foreground">{t('Personal lists hub intro')}</p> |
||||
<SettingRow |
||||
className="clickable" |
||||
onClick={() => navigateToMuteList(toMuteList())} |
||||
> |
||||
<div className="flex items-center gap-3"> |
||||
<VolumeX /> |
||||
<div>{t('Mute list')}</div> |
||||
</div> |
||||
<ChevronRight /> |
||||
</SettingRow> |
||||
{pubkey ? ( |
||||
<SettingRow |
||||
className="clickable" |
||||
onClick={() => navigateToFollowingList(toFollowingList(pubkey))} |
||||
> |
||||
<div className="flex items-center gap-3"> |
||||
<Users /> |
||||
<div>{t('Following list')}</div> |
||||
</div> |
||||
<ChevronRight /> |
||||
</SettingRow> |
||||
) : null} |
||||
{pubkey ? ( |
||||
<SettingRow |
||||
className="clickable" |
||||
onClick={() => navigatePrimary('spells', { spell: 'bookmarks' })} |
||||
> |
||||
<div className="flex items-center gap-3"> |
||||
<Bookmark /> |
||||
<div>{t('Bookmarks spell')}</div> |
||||
</div> |
||||
<ChevronRight /> |
||||
</SettingRow> |
||||
) : null} |
||||
<SettingRow |
||||
className="clickable" |
||||
onClick={() => navigateToSettings(toFollowSetsSettings())} |
||||
> |
||||
<div className="flex items-center gap-3"> |
||||
<Users /> |
||||
<div>{t('Follow sets')}</div> |
||||
</div> |
||||
<ChevronRight /> |
||||
</SettingRow> |
||||
<div className="flex min-h-[52px] items-center gap-3 rounded-lg px-4 py-2 text-sm text-muted-foreground"> |
||||
<Pin className="size-4 shrink-0 opacity-80" /> |
||||
<div>{t('Pinned notes hint')}</div> |
||||
</div> |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
) |
||||
|
||||
PersonalListsSettingsPage.displayName = 'PersonalListsSettingsPage' |
||||
export default PersonalListsSettingsPage |
||||
|
||||
const SettingRow = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>( |
||||
({ children, className, ...props }, ref) => ( |
||||
<div |
||||
ref={ref} |
||||
className={cn( |
||||
'flex h-[52px] select-none items-center justify-between rounded-lg px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0', |
||||
className |
||||
)} |
||||
{...props} |
||||
> |
||||
{children} |
||||
</div> |
||||
) |
||||
) |
||||
SettingRow.displayName = 'SettingRow' |
||||
Loading…
Reference in new issue