Browse Source

expand url page with filters and bookmarks

imwald
Silberengel 1 month ago
parent
commit
85be37b4a0
  1. 8
      src/components/ContentPreview/index.tsx
  2. 32
      src/components/Note/index.tsx
  3. 4
      src/components/NoteStats/index.tsx
  4. 215
      src/components/RssArticleWebBookmarks/index.tsx
  5. 229
      src/components/RssFeedList/index.tsx
  6. 6
      src/components/Settings/SettingsMenuBody.tsx
  7. 4
      src/i18n/locales/ar.ts
  8. 4
      src/i18n/locales/de.ts
  9. 27
      src/i18n/locales/en.ts
  10. 4
      src/i18n/locales/es.ts
  11. 4
      src/i18n/locales/fa.ts
  12. 4
      src/i18n/locales/fr.ts
  13. 4
      src/i18n/locales/hi.ts
  14. 4
      src/i18n/locales/it.ts
  15. 4
      src/i18n/locales/ja.ts
  16. 4
      src/i18n/locales/ko.ts
  17. 4
      src/i18n/locales/pl.ts
  18. 4
      src/i18n/locales/pt-BR.ts
  19. 4
      src/i18n/locales/pt-PT.ts
  20. 4
      src/i18n/locales/ru.ts
  21. 4
      src/i18n/locales/th.ts
  22. 23
      src/i18n/locales/zh.ts
  23. 41
      src/lib/draft-event.ts
  24. 1
      src/lib/link.ts
  25. 3
      src/lib/note-renderable-kinds.ts
  26. 4
      src/lib/relay-auth-feedback.ts
  27. 12
      src/lib/web-bookmark-nip.ts
  28. 16
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  29. 18
      src/pages/primary/SpellsPage/index.tsx
  30. 121
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  31. 11
      src/pages/secondary/RssArticlePage/index.tsx
  32. 2
      src/routes.tsx
  33. 33
      src/services/navigation.service.ts
  34. 55
      src/services/rss-feed.service.ts

8
src/components/ContentPreview/index.tsx

@ -9,6 +9,7 @@ import {
DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { getWebBookmarkArticleUrl } from '@/lib/rss-article'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useMuteListOptional } from '@/contexts/mute-list-context' import { useMuteListOptional } from '@/contexts/mute-list-context'
@ -156,6 +157,13 @@ export default function ContentPreview({
return withKindRow(<HighlightPreview event={event} />) return withKindRow(<HighlightPreview event={event} />)
} }
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(<div className={cn('min-w-0 truncate text-sm', previewBody)}>{line}</div>)
}
if (event.kind === ExtendedKind.POLL) { if (event.kind === ExtendedKind.POLL) {
if (forParentReplyBlurb) { if (forParentReplyBlurb) {
const snippet = parentReplyPollQuestionBlurb(event.content ?? '') const snippet = parentReplyPollQuestionBlurb(event.content ?? '')

32
src/components/Note/index.tsx

@ -29,7 +29,11 @@ import type { HighlightData } from '@/components/PostEditor/HighlightEditor'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 { CreateHighlightContext } from './CreateHighlightContext'
import SelectionHighlightTrigger from './SelectionHighlightTrigger' import SelectionHighlightTrigger from './SelectionHighlightTrigger'
import AudioPlayer from '../AudioPlayer' import AudioPlayer from '../AudioPlayer'
@ -181,6 +185,32 @@ export default function Note({
<div>Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</div> <div>Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</div>
</div> </div>
} }
} else if (event.kind === ExtendedKind.WEB_BOOKMARK) {
const href = getWebBookmarkArticleUrl(event)
const title = event.tags.find((tag) => tag[0] === 'title')?.[1]?.trim()
content = (
<>
{title ? (
<h3 className="mt-2 text-base font-semibold leading-snug break-words">{title}</h3>
) : null}
{href ? (
<div className="mt-2 not-prose max-w-full space-y-2">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline break-all"
>
{href}
</a>
<WebPreview url={href} className="w-full" />
</div>
) : null}
{event.content?.trim() ? (
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap break-words">{event.content}</p>
) : null}
</>
)
} else if (event.kind === ExtendedKind.WIKI_ARTICLE) { } else if (event.kind === ExtendedKind.WIKI_ARTICLE) {
content = showFull ? ( content = showFull ? (
<AsciidocArticle className="mt-2" event={event} /> <AsciidocArticle className="mt-2" event={event} />

4
src/components/NoteStats/index.tsx

@ -81,7 +81,7 @@ export default function NoteStats({
{!isRssArticleRoot && !isZapPoll && ( {!isRssArticleRoot && !isZapPoll && (
<ZapButton event={event} hideCount={hideInteractions} /> <ZapButton event={event} hideCount={hideInteractions} />
)} )}
<BookmarkButton event={event} /> {!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} /> <SeenOnButton event={event} />
</div> </div>
</div> </div>
@ -109,7 +109,7 @@ export default function NoteStats({
)} )}
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<BookmarkButton event={event} /> {!isRssArticleRoot && <BookmarkButton event={event} />}
<SeenOnButton event={event} /> <SeenOnButton event={event} />
</div> </div>
</div> </div>

215
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<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>
)
}

229
src/components/RssFeedList/index.tsx

@ -56,6 +56,26 @@ import {
} from '@/lib/standard-rss-feed-url' } from '@/lib/standard-rss-feed-url'
import { StandardRssFeedUrlInline } from '@/components/StandardRssFeedUrlRow' import { StandardRssFeedUrlInline } from '@/components/StandardRssFeedUrlRow'
/** Cutoff timestamp (ms) for RSS+Web time filters; `null` = all time. */
function getRssTimeFilterCutoffMs(timeFilter: string): number | null {
if (timeFilter === 'all') return null
const now = Date.now()
switch (timeFilter) {
case 'hour':
return now - 60 * 60 * 1000
case 'hours24':
return now - 24 * 60 * 60 * 1000
case 'hours48':
return now - 48 * 60 * 60 * 1000
case 'week':
return now - 7 * 24 * 60 * 60 * 1000
case 'month':
return now - 30 * 24 * 60 * 60 * 1000
default:
return null
}
}
function ManualRssUrlAddRow({ function ManualRssUrlAddRow({
className, className,
onUrlAdded onUrlAdded
@ -194,6 +214,49 @@ export default function RssFeedList() {
/** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */ /** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */
const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0) const [relayDiscoveryTick, setRelayDiscoveryTick] = useState(0)
/** Subscribed feed URLs (same rules as RSS load effect) — used for IndexedDB snapshot. */
const resolvedFeedUrls = useMemo((): string[] => {
if (pubkey && rssFeedListEvent) {
try {
return rssFeedListEvent.tags
.filter((tag) => tag[0] === 'u' && tag[1])
.map((tag) => tag[1] as string)
.filter((url): url is string => {
if (typeof url !== 'string') return false
return url.trim().length > 0
})
} catch {
return []
}
}
return DEFAULT_RSS_FEEDS
}, [pubkey, rssFeedListEvent])
/** Full IndexedDB slice for current subscriptions; merged into the filter pipeline so search/time/feed use all cached rows. */
const [idbRssSnapshot, setIdbRssSnapshot] = useState<TRssFeedItem[]>([])
const refreshIdbRssSnapshot = useCallback(() => {
if (resolvedFeedUrls.length === 0) {
setIdbRssSnapshot([])
return
}
void rssFeedService.getCachedItemsForFeedUrls(resolvedFeedUrls).then(setIdbRssSnapshot)
}, [resolvedFeedUrls])
useEffect(() => {
refreshIdbRssSnapshot()
}, [refreshIdbRssSnapshot, items])
useEffect(() => {
const id = window.setInterval(() => refreshIdbRssSnapshot(), 20_000)
return () => clearInterval(id)
}, [refreshIdbRssSnapshot])
const mergedRssItems = useMemo(
() => rssFeedService.mergeRssFeedItemLists([items, idbRssSnapshot]),
[items, idbRssSnapshot]
)
// Listen for filter toggle events // Listen for filter toggle events
useEffect(() => { useEffect(() => {
const handleToggleFilters = () => { const handleToggleFilters = () => {
@ -463,7 +526,7 @@ export default function RssFeedList() {
const availableFeeds = useMemo(() => { const availableFeeds = useMemo(() => {
const feedMap = new Map<string, { url: string; title: string }>() const feedMap = new Map<string, { url: string; title: string }>()
items.forEach((item) => { mergedRssItems.forEach((item) => {
const normalizedUrl = normalizeFeedUrl(item.feedUrl) const normalizedUrl = normalizeFeedUrl(item.feedUrl)
if (!feedMap.has(normalizedUrl)) { if (!feedMap.has(normalizedUrl)) {
const profile = getStandardRssFeedProfile(normalizedUrl) const profile = getStandardRssFeedProfile(normalizedUrl)
@ -477,7 +540,7 @@ export default function RssFeedList() {
} }
}) })
return Array.from(feedMap.values()) return Array.from(feedMap.values())
}, [items, t]) }, [mergedRssItems, t])
// Helper function to truncate text // Helper function to truncate text
const truncateText = (text: string, maxLength: number): string => { const truncateText = (text: string, maxLength: number): string => {
@ -502,9 +565,22 @@ export default function RssFeedList() {
} }
} }
/** Single-select value for the RSS tab source dropdown (multi-select from filter popover → `__multi__`). */
const rssSourceDropdownValue = useMemo(() => {
if (selectedFeeds.includes('all') || selectedFeeds.length === 0) return 'all'
const urls = selectedFeeds.filter((f) => f !== 'all').map((f) => normalizeFeedUrl(f))
if (urls.length === 1) return urls[0]!
return '__multi__'
}, [selectedFeeds])
const onRssSourceSelect = useCallback((value: string) => {
if (value === 'all') setSelectedFeeds(['all'])
else setSelectedFeeds([value.trim().replace(/\/$/, '')])
}, [])
/** Feed + time only (search is applied after merge so URL rows and links match too). */ /** Feed + time only (search is applied after merge so URL rows and links match too). */
const baseFilteredItems = useMemo(() => { const baseFilteredItems = useMemo(() => {
let filtered = items let filtered = mergedRssItems
if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) { if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) {
const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f)) const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f))
@ -513,25 +589,8 @@ export default function RssFeedList() {
) )
} }
if (timeFilter !== 'all') { const cutoffTime = getRssTimeFilterCutoffMs(timeFilter)
const now = Date.now() if (cutoffTime !== null) {
let cutoffTime = 0
switch (timeFilter) {
case 'hour':
cutoffTime = now - 60 * 60 * 1000
break
case 'day':
cutoffTime = now - 24 * 60 * 60 * 1000
break
case 'week':
cutoffTime = now - 7 * 24 * 60 * 60 * 1000
break
case 'month':
cutoffTime = now - 30 * 24 * 60 * 60 * 1000
break
}
filtered = filtered.filter((item) => { filtered = filtered.filter((item) => {
if (!item.pubDate) return false if (!item.pubDate) return false
return item.pubDate.getTime() >= cutoffTime return item.pubDate.getTime() >= cutoffTime
@ -539,7 +598,7 @@ export default function RssFeedList() {
} }
return filtered return filtered
}, [items, selectedFeeds, timeFilter]) }, [mergedRssItems, selectedFeeds, timeFilter])
/** When “hide clutter” is on, drop those entries from the feed (not only from URL cards). */ /** When “hide clutter” is on, drop those entries from the feed (not only from URL cards). */
const rssWebItemsRespectingClutterPref = useMemo(() => { const rssWebItemsRespectingClutterPref = useMemo(() => {
@ -559,10 +618,28 @@ export default function RssFeedList() {
item.description.toLowerCase().includes(query) || item.description.toLowerCase().includes(query) ||
(item.feedTitle || '').toLowerCase().includes(query) || (item.feedTitle || '').toLowerCase().includes(query) ||
(item.link || '').toLowerCase().includes(query) || (item.link || '').toLowerCase().includes(query) ||
(item.guid || '').toLowerCase().includes(query) (item.guid || '').toLowerCase().includes(query) ||
(item.feedUrl || '').toLowerCase().includes(query) ||
(item.feedDescription || '').toLowerCase().includes(query) ||
(item.feedImage || '').toLowerCase().includes(query)
) )
}, []) }, [])
/** Match article / preview URLs and hostname (e.g. "spiegel" → www.spiegel.de paths). */
const articleHttpUrlMatchesSearch = useCallback((url: string, q: string) => {
const query = q.toLowerCase().trim()
if (!query) return true
const lower = url.toLowerCase()
if (lower.includes(query)) return true
try {
const host = new URL(url).hostname.toLowerCase()
if (host.includes(query)) return true
} catch {
/* ignore */
}
return false
}, [])
type CombinedFeedRow = type CombinedFeedRow =
| { | {
kind: 'web' kind: 'web'
@ -640,17 +717,30 @@ export default function RssFeedList() {
hideUnifiedClutter hideUnifiedClutter
]) ])
/** Time window applies to URL cards too (latestPub / item pubDate), not only RSS rows. */
const combinedFeedRowsInTimeRange = useMemo((): CombinedFeedRow[] => {
const cutoff = getRssTimeFilterCutoffMs(timeFilter)
if (cutoff === null) return combinedFeedRows
return combinedFeedRows.filter((row) => {
if (row.kind === 'rss') {
const ts = row.item.pubDate?.getTime() ?? 0
return ts >= cutoff
}
return row.latestPub >= cutoff
})
}, [combinedFeedRows, timeFilter])
const combinedFeedRowsForSearch = useMemo((): CombinedFeedRow[] => { const combinedFeedRowsForSearch = useMemo((): CombinedFeedRow[] => {
const q = searchQuery.trim() const q = searchQuery.trim()
if (!q) return combinedFeedRows if (!q) return combinedFeedRowsInTimeRange
return combinedFeedRows.filter((row) => { return combinedFeedRowsInTimeRange.filter((row) => {
if (row.kind === 'rss') { if (row.kind === 'rss') {
return rssItemMatchesSearch(row.item, q) return rssItemMatchesSearch(row.item, q)
} }
if (row.canonicalUrl.toLowerCase().includes(q.toLowerCase())) return true if (articleHttpUrlMatchesSearch(row.canonicalUrl, q)) return true
return row.rssItems.some((it) => rssItemMatchesSearch(it, q)) return row.rssItems.some((it) => rssItemMatchesSearch(it, q))
}) })
}, [combinedFeedRows, searchQuery, rssItemMatchesSearch]) }, [combinedFeedRowsInTimeRange, searchQuery, rssItemMatchesSearch, articleHttpUrlMatchesSearch])
const urlScopeRows = useMemo((): UnifiedFeedRow[] => { const urlScopeRows = useMemo((): UnifiedFeedRow[] => {
return combinedFeedRowsForSearch return combinedFeedRowsForSearch
@ -804,7 +894,7 @@ export default function RssFeedList() {
) )
} }
if (items.length === 0 && manualWebEntries.length === 0) { if (mergedRssItems.length === 0 && manualWebEntries.length === 0) {
return ( return (
<div className="space-y-4 px-4 py-6"> <div className="space-y-4 px-4 py-6">
<ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} /> <ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
@ -880,16 +970,63 @@ export default function RssFeedList() {
})} })}
</p> </p>
</div> </div>
<div className="relative w-full max-w-xl"> <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground sm:h-4 sm:w-4" /> <div className="relative w-full max-w-xl flex-1 min-w-0">
<Input <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground sm:h-4 sm:w-4" />
type="search" <Input
placeholder={t('Search...')} type="search"
value={searchQuery} placeholder={t('Search...')}
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
className="h-8 w-full pl-8 text-xs sm:h-9 sm:pl-9 sm:text-sm" onChange={(e) => setSearchQuery(e.target.value)}
aria-label={t('Search...')} className="h-8 w-full pl-8 text-xs sm:h-9 sm:pl-9 sm:text-sm"
/> aria-label={t('Search...')}
/>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-[10rem] flex-col gap-0.5">
<Label htmlFor="rss-time-range" className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
{t('Time range')}
</Label>
<Select value={timeFilter} onValueChange={setTimeFilter}>
<SelectTrigger id="rss-time-range" className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder={t('All time')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All time')}</SelectItem>
<SelectItem value="hour">{t('Last hour')}</SelectItem>
<SelectItem value="hours24">{t('Last 24 hours')}</SelectItem>
<SelectItem value="hours48">{t('Last 48 hours')}</SelectItem>
<SelectItem value="week">{t('Last week')}</SelectItem>
<SelectItem value="month">{t('Last month')}</SelectItem>
</SelectContent>
</Select>
</div>
{feedScope === 'rss' ? (
<div className="flex min-w-[12rem] flex-col gap-0.5">
<Label htmlFor="rss-source-filter" className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
{t('Filter by RSS source')}
</Label>
<Select value={rssSourceDropdownValue} onValueChange={onRssSourceSelect}>
<SelectTrigger id="rss-source-filter" className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder={t('All feeds')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All feeds')}</SelectItem>
{rssSourceDropdownValue === '__multi__' ? (
<SelectItem value="__multi__" disabled>
{t('{{count}} feeds', { count: selectedFeeds.filter((f) => f !== 'all').length })}
</SelectItem>
) : null}
{availableFeeds.map((feed) => (
<SelectItem key={feed.url} value={feed.url}>
<span className="truncate">{feed.title}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
</div>
</div> </div>
</div> </div>
@ -954,20 +1091,6 @@ export default function RssFeedList() {
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* Time Filter */}
<Select value={timeFilter} onValueChange={setTimeFilter}>
<SelectTrigger className="h-8 text-xs md:text-sm md:h-9 flex-shrink-0 w-full md:w-auto" style={{ minWidth: isSmallScreen ? '100%' : '120px' }}>
<SelectValue placeholder={t('All time')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('All time')}</SelectItem>
<SelectItem value="hour">{t('Last hour')}</SelectItem>
<SelectItem value="day">{t('Last day')}</SelectItem>
<SelectItem value="week">{t('Last week')}</SelectItem>
<SelectItem value="month">{t('Last month')}</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
)} )}

6
src/components/Settings/SettingsMenuBody.tsx

@ -7,7 +7,7 @@ import {
toTranslation, toTranslation,
toWallet, toWallet,
toRssFeedSettings, toRssFeedSettings,
toFollowSetsSettings toPersonalListsSettings
} from '@/lib/link' } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartSettingsNavigation } from '@/PageManager' import { useSmartSettingsNavigation } from '@/PageManager'
@ -101,10 +101,10 @@ export default function SettingsMenuBody({ className }: { className?: string })
</SettingItem> </SettingItem>
)} )}
{!!pubkey && ( {!!pubkey && (
<SettingItem className="clickable" onClick={() => navigateToSettings(toFollowSetsSettings())}> <SettingItem className="clickable" onClick={() => navigateToSettings(toPersonalListsSettings())}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Users /> <Users />
<div>{t('Follow sets')}</div> <div>{t('Personal Lists')}</div>
</div> </div>
<ChevronRight /> <ChevronRight />
</SettingItem> </SettingItem>

4
src/i18n/locales/ar.ts

@ -1046,6 +1046,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/de.ts

@ -1081,6 +1081,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

27
src/i18n/locales/en.ts

@ -1059,6 +1059,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',
@ -1570,6 +1574,29 @@ export default {
standardRssFeed_medium: 'Medium', standardRssFeed_medium: 'Medium',
'RSS Feed Settings': 'RSS Feed Settings', 'RSS Feed Settings': 'RSS Feed Settings',
'Follow sets': 'Follow sets', 'Follow sets': 'Follow sets',
'Personal Lists': 'Personal Lists',
'Personal lists hub intro':
'Mute list, who you follow, NIP-51 bookmarks, and pins. Web page bookmarks (NIP-B0, kind 39701) are separate: save them from an article’s side panel or open the Bookmarks spell to see note bookmarks and web bookmarks together.',
'Mute list': 'Mute list',
'Following list': 'Following list',
'Bookmarks spell': 'Bookmarks spell',
'Pinned notes hint':
'Pinned notes: use the note menu (⋯) on a note and choose pin to profile. Pins appear on your profile.',
'No NIP-51 bookmarks or web bookmarks yet.':
'No NIP-51 bookmarks or web bookmarks yet.',
'Web bookmarks': 'Web bookmarks',
'Web bookmark': 'Web bookmark',
'Web bookmarks NIP intro':
'Web bookmarks are stored as kind 39701 events, not in your NIP-51 bookmark list.',
'Log in to save web bookmarks':
'Log in to save web bookmarks for this page (NIP-B0, kind 39701).',
'Remove web bookmark': 'Remove web bookmark',
'No web bookmark for this URL yet': 'You have not saved a web bookmark for this URL yet.',
'Page title': 'Page title',
'Short description': 'Short description',
'Save web bookmark': 'Save web bookmark',
'Sign in to publish web bookmark':
'Please log in with a signing key to save web bookmarks.',
'Follow sets settings intro': 'Follow sets settings intro':
'NIP-51 follow sets (kind 30000) group people for custom feeds (for example in Spells). Lists are published to your NIP-65 outboxes and profile discovery relays.', 'NIP-51 follow sets (kind 30000) group people for custom feeds (for example in Spells). Lists are published to your NIP-65 outboxes and profile discovery relays.',
'New follow set': 'New follow set', 'New follow set': 'New follow set',

4
src/i18n/locales/es.ts

@ -1054,6 +1054,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/fa.ts

@ -1050,6 +1050,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/fr.ts

@ -1059,6 +1059,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/hi.ts

@ -1052,6 +1052,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/it.ts

@ -1055,6 +1055,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/ja.ts

@ -1050,6 +1050,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/ko.ts

@ -1048,6 +1048,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/pl.ts

@ -1053,6 +1053,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/pt-BR.ts

@ -1052,6 +1052,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/pt-PT.ts

@ -1054,6 +1054,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/ru.ts

@ -1055,6 +1055,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/th.ts

@ -1045,6 +1045,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',

23
src/i18n/locales/zh.ts

@ -1040,6 +1040,10 @@ export default {
'Last day': 'Last day', 'Last day': 'Last day',
'Last week': 'Last week', 'Last week': 'Last week',
'Last month': 'Last month', 'Last month': 'Last month',
'Last 24 hours': 'Last 24 hours',
'Last 48 hours': 'Last 48 hours',
'Time range': 'Time range',
'Filter by RSS source': 'Filter by RSS source',
'No items match your filters': 'No items match your filters', 'No items match your filters': 'No items match your filters',
'Search...': 'Search...', 'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds', '{{count}} feeds': '{{count}} feeds',
@ -1551,6 +1555,25 @@ export default {
standardRssFeed_medium: 'Medium', standardRssFeed_medium: 'Medium',
'RSS Feed Settings': 'RSS Feed Settings', 'RSS Feed Settings': 'RSS Feed Settings',
'Follow sets': 'Follow sets', 'Follow sets': 'Follow sets',
'Personal Lists': '个人列表',
'Personal lists hub intro':
'静音列表、关注的人、NIP-51 书签与置顶。网页书签(NIP-B0,kind 39701)另计:可在文章侧栏保存,或在「咒语」里打开书签流同时查看笔记书签与网页书签。',
'Mute list': '静音列表',
'Following list': '关注列表',
'Bookmarks spell': '书签咒语',
'Pinned notes hint':
'置顶笔记:在笔记菜单(⋯)中选择置顶到资料。置顶会显示在你的个人资料上。',
'No NIP-51 bookmarks or web bookmarks yet.': '尚无 NIP-51 书签或网页书签。',
'Web bookmarks': '网页书签',
'Web bookmark': '网页书签',
'Web bookmarks NIP intro': '网页书签以 kind 39701 事件存储,不在 NIP-51 书签列表中。',
'Log in to save web bookmarks': '请登录以保存此页的网页书签(NIP-B0,kind 39701)。',
'Remove web bookmark': '移除网页书签',
'No web bookmark for this URL yet': '你尚未为此 URL 保存网页书签。',
'Page title': '页面标题',
'Short description': '简短描述',
'Save web bookmark': '保存网页书签',
'Sign in to publish web bookmark': '请使用可签名密钥登录以保存网页书签。',
'Follow sets settings intro': 'Follow sets settings intro':
'NIP-51 follow sets (kind 30000) group people for custom feeds (for example in Spells). Lists are published to your NIP-65 outboxes and profile discovery relays.', 'NIP-51 follow sets (kind 30000) group people for custom feeds (for example in Spells). Lists are published to your NIP-65 outboxes and profile discovery relays.',
'New follow set': 'New follow set', 'New follow set': 'New follow set',

41
src/lib/draft-event.ts

@ -31,6 +31,7 @@ import {
NIP22_URL_SCOPE_KIND NIP22_URL_SCOPE_KIND
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random' import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag' import { generateBech32IdFromETag, tagNameEquals } from './tag'
@ -902,6 +903,46 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft
} }
} }
/** NIP-B0 (kind 39701): parameterized web bookmark; `d` = URL without scheme, `i`/`I` = canonical http(s) URL. */
export function createWebBookmarkDraftEvent(options: {
url: string
title?: string
note?: string
/** Preserve first publication time when editing (unix seconds string). */
publishedAtUnix?: string
topicTags?: string[]
}): TDraftEvent {
const raw = options.url.trim()
if (!raw) throw new Error('Web bookmark URL is required')
const href = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
const canonical = canonicalizeHttpUrlForITags(canonicalizeRssArticleUrl(href))
const d = urlToWebBookmarkDTag(canonical)
if (!d) throw new Error('Invalid web bookmark URL')
const tags: string[][] = [
['d', d],
['I', canonical],
['i', canonical]
]
const title = options.title?.trim()
if (title) tags.push(['title', title])
const now = dayjs().unix()
tags.push(['published_at', options.publishedAtUnix ?? String(now)])
for (const topic of options.topicTags ?? []) {
const n = normalizeTopic(topic)
if (n) tags.push(['t', n])
}
return {
kind: ExtendedKind.WEB_BOOKMARK,
content: options.note?.trim() ?? '',
tags,
created_at: now
}
}
export function createInterestListDraftEvent(topics: string[], content = ''): TDraftEvent { export function createInterestListDraftEvent(topics: string[], content = ''): TDraftEvent {
return { return {
kind: 10015, kind: 10015,

1
src/lib/link.ts

@ -73,6 +73,7 @@ export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds' export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toFollowSetsSettings = () => '/settings/follow-sets' export const toFollowSetsSettings = () => '/settings/follow-sets'
export const toCacheSettings = () => '/settings/cache' export const toCacheSettings = () => '/settings/cache'
export const toPersonalListsSettings = () => '/settings/personal-lists'
export const toProfileEditor = () => '/profile-editor' export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews` export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`

3
src/lib/note-renderable-kinds.ts

@ -19,7 +19,8 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.CITATION_EXTERNAL, ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY, ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT, ExtendedKind.CITATION_PROMPT,
ExtendedKind.ZAP_POLL ExtendedKind.ZAP_POLL,
ExtendedKind.WEB_BOOKMARK
]) ])
/** /**

4
src/lib/relay-auth-feedback.ts

@ -22,6 +22,10 @@ function relayLabel(url: string): string {
/** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */ /** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */
export function notifyRelayNip42Accepted(url: string, okReason?: string): void { export function notifyRelayNip42Accepted(url: string, okReason?: string): void {
const key = sessionKeyForRelay(url)
if (!key || nip42NotifiedAccept.has(key)) return
nip42NotifiedAccept.add(key)
const relay = relayLabel(url) const relay = relayLabel(url)
const detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : '' const detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : ''
toast.success( toast.success(

12
src/lib/web-bookmark-nip.ts

@ -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, '')
}

16
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -194,3 +194,19 @@ export function buildBookmarksSubRequests(bookmarkListEvent: Event | null, urls:
const slice = ids.slice(0, cap) const slice = ids.slice(0, cap)
return [{ urls, filter: { ids: slice, limit: slice.length } }] return [{ urls, filter: { ids: slice, limit: slice.length } }]
} }
/** NIP-B0 web bookmarks (kind 39701) authored by the user — merged with NIP-51 id bookmarks in the Bookmarks spell. */
export function buildWebBookmarksSpellSubRequests(pubkey: string, urls: string[]): TFeedSubRequest[] {
if (!pubkey || !urls.length) return []
const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim()
return [
{
urls,
filter: {
authors: [pk],
kinds: [ExtendedKind.WEB_BOOKMARK],
limit: FAUX_SPELL_EVENT_LIMIT
}
}
]
}

18
src/pages/primary/SpellsPage/index.tsx

@ -104,6 +104,7 @@ import {
appendCuratedReadOnlyRelays, appendCuratedReadOnlyRelays,
applyFauxSpellCapsToSubRequests, applyFauxSpellCapsToSubRequests,
buildBookmarksSubRequests, buildBookmarksSubRequests,
buildWebBookmarksSpellSubRequests,
buildCalendarSpellFilter, buildCalendarSpellFilter,
buildDiscussionFilter, buildDiscussionFilter,
buildInterestsSubRequests, buildInterestsSubRequests,
@ -868,7 +869,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return [] if (!pubkey) return []
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
return buildBookmarksSubRequests(bookmarkListEvent, urls) const idReqs = buildBookmarksSubRequests(bookmarkListEvent, urls)
const webReqs = buildWebBookmarksSpellSubRequests(pubkey, urls)
return [...idReqs, ...webReqs]
} }
if (selectedFauxSpell === 'followPacks') { if (selectedFauxSpell === 'followPacks') {
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
@ -1095,7 +1098,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [...DEFAULT_FEED_SHOW_KINDS] return [...DEFAULT_FEED_SHOW_KINDS]
} }
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
return [...DEFAULT_FEED_SHOW_KINDS] const out = [...DEFAULT_FEED_SHOW_KINDS]
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK)
return out.sort((a, b) => a - b)
} }
if (!selectedSpell) return [1] if (!selectedSpell) return [1]
const kinds = selectedSpell.tags const kinds = selectedSpell.tags
@ -1206,7 +1211,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const fauxNoteListUseFilterAsIs = useMemo(() => { const fauxNoteListUseFilterAsIs = useMemo(() => {
if (!selectedFauxSpell) return true if (!selectedFauxSpell) return true
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false
return selectedFauxSpell !== 'bookmarks' return true
}, [selectedFauxSpell]) }, [selectedFauxSpell])
const notificationsMentionExtraHide = useCallback( const notificationsMentionExtraHide = useCallback(
@ -1218,7 +1223,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const fauxFeedEmptyMessage = useMemo(() => { const fauxFeedEmptyMessage = useMemo(() => {
if (!selectedFauxSpell || fauxSubRequests.length > 0) return null if (!selectedFauxSpell || fauxSubRequests.length > 0) return null
if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.')
if (selectedFauxSpell === 'bookmarks') return t('No bookmarked notes with id tags yet.') if (selectedFauxSpell === 'bookmarks')
return t('No NIP-51 bookmarks or web bookmarks yet.')
if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.') if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.')
if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty')
return t('Nothing to load for this feed.') return t('Nothing to load for this feed.')
@ -1699,7 +1705,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
? NOTIFICATION_SPELL_LOADING_SAFETY_MS ? NOTIFICATION_SPELL_LOADING_SAFETY_MS
: undefined : undefined
} }
clientSideKindFilter={selectedFauxSpell === 'notifications'} clientSideKindFilter={
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks'
}
useFilterAsIs={fauxNoteListUseFilterAsIs} useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false} oneShotFetch={false}
showKind1OPs={ showKind1OPs={

121
src/pages/secondary/PersonalListsSettingsPage/index.tsx

@ -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'

11
src/pages/secondary/RssArticlePage/index.tsx

@ -1,5 +1,6 @@
import NoteInteractions from '@/components/NoteInteractions' import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import RssArticleWebBookmarks from '@/components/RssArticleWebBookmarks'
import RssFeedItem from '@/components/RssFeedItem' import RssFeedItem from '@/components/RssFeedItem'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -291,6 +292,11 @@ const RssArticlePage = forwardRef(
</Button> </Button>
</div> </div>
) : null} ) : null}
{isHttpArticleUrl(articleUrl) ? (
<div className="w-full pt-1">
<RssArticleWebBookmarks articleUrl={articleUrl} />
</div>
) : null}
{showNostrThread && syntheticRoot ? ( {showNostrThread && syntheticRoot ? (
<div className="px-0 w-full"> <div className="px-0 w-full">
<NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes /> <NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting displayTopZapsAndLikes />
@ -374,6 +380,11 @@ const RssArticlePage = forwardRef(
/> />
))} ))}
</div> </div>
{isHttpArticleUrl(articleUrl) ? (
<div className="pt-2">
<RssArticleWebBookmarks articleUrl={articleUrl} />
</div>
) : null}
</div> </div>
{showNostrThread && syntheticRoot ? ( {showNostrThread && syntheticRoot ? (
<div className="px-4 w-full"> <div className="px-4 w-full">

2
src/routes.tsx

@ -25,6 +25,7 @@ const RelaySettingsPageLazy = lazy(() => import('./pages/secondary/RelaySettings
const CacheSettingsPageLazy = lazy(() => import('./pages/secondary/CacheSettingsPage')) const CacheSettingsPageLazy = lazy(() => import('./pages/secondary/CacheSettingsPage'))
const RssFeedSettingsPageLazy = lazy(() => import('./pages/secondary/RssFeedSettingsPage')) const RssFeedSettingsPageLazy = lazy(() => import('./pages/secondary/RssFeedSettingsPage'))
const FollowSetsSettingsPageLazy = lazy(() => import('./pages/secondary/FollowSetsSettingsPage')) const FollowSetsSettingsPageLazy = lazy(() => import('./pages/secondary/FollowSetsSettingsPage'))
const PersonalListsSettingsPageLazy = lazy(() => import('./pages/secondary/PersonalListsSettingsPage'))
const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage')) const SearchPageLazy = lazy(() => import('./pages/secondary/SearchPage'))
const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage')) const SettingsPageLazy = lazy(() => import('./pages/secondary/SettingsPage'))
const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage')) const TranslationPageLazy = lazy(() => import('./pages/secondary/TranslationPage'))
@ -81,6 +82,7 @@ const ROUTES = [
{ path: '/settings/translation', element: SR(TranslationPageLazy) }, { path: '/settings/translation', element: SR(TranslationPageLazy) },
{ path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) }, { path: '/settings/rss-feeds', element: SR(RssFeedSettingsPageLazy) },
{ path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) }, { path: '/settings/follow-sets', element: SR(FollowSetsSettingsPageLazy) },
{ path: '/settings/personal-lists', element: SR(PersonalListsSettingsPageLazy) },
{ path: '/profile-editor', element: SR(ProfileEditorPageLazy) }, { path: '/profile-editor', element: SR(ProfileEditorPageLazy) },
{ path: '/mutes', element: SR(MuteListPageLazy) }, { path: '/mutes', element: SR(MuteListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) } { path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }

33
src/services/navigation.service.ts

@ -16,6 +16,8 @@ import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage' import TranslationPage from '@/pages/secondary/TranslationPage'
import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage' import RssFeedSettingsPage from '@/pages/secondary/RssFeedSettingsPage'
import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage' import FollowSetsSettingsPage from '@/pages/secondary/FollowSetsSettingsPage'
import CacheSettingsPage from '@/pages/secondary/CacheSettingsPage'
import PersonalListsSettingsPage from '@/pages/secondary/PersonalListsSettingsPage'
import NotePage from '@/pages/secondary/NotePage' import NotePage from '@/pages/secondary/NotePage'
import SecondaryProfilePage from '@/pages/secondary/ProfilePage' import SecondaryProfilePage from '@/pages/secondary/ProfilePage'
import FollowingListPage from '@/pages/secondary/FollowingListPage' import FollowingListPage from '@/pages/secondary/FollowingListPage'
@ -68,13 +70,26 @@ export class URLParser {
} }
static getSettingsSubPageType(url: string): string { static getSettingsSubPageType(url: string): string {
if (url.includes('/general')) return 'general' try {
if (url.includes('/relays')) return 'relays' const pathOnly = url.split('?')[0].split('#')[0]
if (url.includes('/wallet')) return 'wallet' const parts = pathOnly.split('/').filter(Boolean)
if (url.includes('/posts')) return 'posts' if (parts[0] !== 'settings') return 'general'
if (url.includes('/translation')) return 'translation' const sub = parts[1] ?? ''
if (url.includes('/rss-feeds')) return 'rss-feeds' const known = new Set([
return 'general' 'general',
'relays',
'wallet',
'posts',
'translation',
'rss-feeds',
'follow-sets',
'cache',
'personal-lists'
])
return known.has(sub) ? sub : 'general'
} catch {
return 'general'
}
} }
} }
@ -134,6 +149,10 @@ export class ComponentFactory {
return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(RssFeedSettingsPage, { index: 0, hideTitlebar: true })
case 'follow-sets': case 'follow-sets':
return React.createElement(FollowSetsSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(FollowSetsSettingsPage, { index: 0, hideTitlebar: true })
case 'cache':
return React.createElement(CacheSettingsPage, { index: 0, hideTitlebar: true })
case 'personal-lists':
return React.createElement(PersonalListsSettingsPage, { index: 0, hideTitlebar: true })
default: default:
return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true }) return React.createElement(GeneralSettingsPage, { index: 0, hideTitlebar: true })
} }

55
src/services/rss-feed.service.ts

@ -1702,6 +1702,61 @@ class RssFeedService {
} }
} }
/**
* All RSS rows for these feed URLs from IndexedDB only (no network).
* Lets the RSS+Web UI search and filter against the full persisted cache.
*/
async getCachedItemsForFeedUrls(feedUrls: string[]): Promise<RssFeedItem[]> {
if (feedUrls.length === 0) return []
await this.ensureRssFeedAttemptedKeysLoaded()
try {
const allCachedItems = await indexedDb.getRssFeedItems()
const normalizedRequestedUrls = new Set(feedUrls.map((u) => this.normalizeRssFeedKeyUrl(u)))
let cachedItems = allCachedItems.filter((item) =>
normalizedRequestedUrls.has(this.normalizeRssFeedKeyUrl(item.feedUrl))
)
cachedItems = cachedItems.map((item) => ({
...item,
pubDate: this.parseItemPubDate(item)
}))
cachedItems.sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
return cachedItems
} catch (error) {
logger.warn('[RssFeedService] getCachedItemsForFeedUrls failed', { error })
return []
}
}
/**
* Dedupe by normalized feed URL + guid; when both have pubDate, keep the newer row.
*/
mergeRssFeedItemLists(lists: RssFeedItem[][]): RssFeedItem[] {
const map = new Map<string, RssFeedItem>()
for (const list of lists) {
for (const item of list) {
const key = `${this.normalizeRssFeedKeyUrl(item.feedUrl)}:${item.guid}`
const existing = map.get(key)
const nextDate = this.parseItemPubDate(item)?.getTime() ?? 0
const prevDate = existing ? this.parseItemPubDate(existing)?.getTime() ?? 0 : -1
if (!existing || nextDate >= prevDate) {
map.set(key, {
...item,
pubDate: this.parseItemPubDate(item)
})
}
}
}
return Array.from(map.values()).sort((a, b) => {
const dateA = a.pubDate?.getTime() || 0
const dateB = b.pubDate?.getTime() || 0
return dateB - dateA
})
}
/** /**
* Clear cache for a specific feed or all feeds * Clear cache for a specific feed or all feeds
*/ */

Loading…
Cancel
Save