34 changed files with 845 additions and 72 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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