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 { @@ -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({ @@ -156,6 +157,13 @@ export default function ContentPreview({
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 (forParentReplyBlurb) {
const snippet = parentReplyPollQuestionBlurb(event.content ?? '')

32
src/components/Note/index.tsx

@ -29,7 +29,11 @@ import type { HighlightData } from '@/components/PostEditor/HighlightEditor' @@ -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({ @@ -181,6 +185,32 @@ export default function Note({
<div>Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</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) {
content = showFull ? (
<AsciidocArticle className="mt-2" event={event} />

4
src/components/NoteStats/index.tsx

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

215
src/components/RssArticleWebBookmarks/index.tsx

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

229
src/components/RssFeedList/index.tsx

@ -56,6 +56,26 @@ import { @@ -56,6 +56,26 @@ import {
} from '@/lib/standard-rss-feed-url'
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({
className,
onUrlAdded
@ -194,6 +214,49 @@ export default function RssFeedList() { @@ -194,6 +214,49 @@ export default function RssFeedList() {
/** Bump to re-run relay URL discovery after publishing a kind-17 reaction. */
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
useEffect(() => {
const handleToggleFilters = () => {
@ -463,7 +526,7 @@ export default function RssFeedList() { @@ -463,7 +526,7 @@ export default function RssFeedList() {
const availableFeeds = useMemo(() => {
const feedMap = new Map<string, { url: string; title: string }>()
items.forEach((item) => {
mergedRssItems.forEach((item) => {
const normalizedUrl = normalizeFeedUrl(item.feedUrl)
if (!feedMap.has(normalizedUrl)) {
const profile = getStandardRssFeedProfile(normalizedUrl)
@ -477,7 +540,7 @@ export default function RssFeedList() { @@ -477,7 +540,7 @@ export default function RssFeedList() {
}
})
return Array.from(feedMap.values())
}, [items, t])
}, [mergedRssItems, t])
// Helper function to truncate text
const truncateText = (text: string, maxLength: number): string => {
@ -502,9 +565,22 @@ export default function RssFeedList() { @@ -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). */
const baseFilteredItems = useMemo(() => {
let filtered = items
let filtered = mergedRssItems
if (!selectedFeeds.includes('all') && selectedFeeds.length > 0) {
const normalizedSelectedFeeds = selectedFeeds.map((f) => normalizeFeedUrl(f))
@ -513,25 +589,8 @@ export default function RssFeedList() { @@ -513,25 +589,8 @@ export default function RssFeedList() {
)
}
if (timeFilter !== 'all') {
const now = Date.now()
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
}
const cutoffTime = getRssTimeFilterCutoffMs(timeFilter)
if (cutoffTime !== null) {
filtered = filtered.filter((item) => {
if (!item.pubDate) return false
return item.pubDate.getTime() >= cutoffTime
@ -539,7 +598,7 @@ export default function RssFeedList() { @@ -539,7 +598,7 @@ export default function RssFeedList() {
}
return filtered
}, [items, selectedFeeds, timeFilter])
}, [mergedRssItems, selectedFeeds, timeFilter])
/** When “hide clutter” is on, drop those entries from the feed (not only from URL cards). */
const rssWebItemsRespectingClutterPref = useMemo(() => {
@ -559,10 +618,28 @@ export default function RssFeedList() { @@ -559,10 +618,28 @@ export default function RssFeedList() {
item.description.toLowerCase().includes(query) ||
(item.feedTitle || '').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 =
| {
kind: 'web'
@ -640,17 +717,30 @@ export default function RssFeedList() { @@ -640,17 +717,30 @@ export default function RssFeedList() {
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 q = searchQuery.trim()
if (!q) return combinedFeedRows
return combinedFeedRows.filter((row) => {
if (!q) return combinedFeedRowsInTimeRange
return combinedFeedRowsInTimeRange.filter((row) => {
if (row.kind === 'rss') {
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))
})
}, [combinedFeedRows, searchQuery, rssItemMatchesSearch])
}, [combinedFeedRowsInTimeRange, searchQuery, rssItemMatchesSearch, articleHttpUrlMatchesSearch])
const urlScopeRows = useMemo((): UnifiedFeedRow[] => {
return combinedFeedRowsForSearch
@ -804,7 +894,7 @@ export default function RssFeedList() { @@ -804,7 +894,7 @@ export default function RssFeedList() {
)
}
if (items.length === 0 && manualWebEntries.length === 0) {
if (mergedRssItems.length === 0 && manualWebEntries.length === 0) {
return (
<div className="space-y-4 px-4 py-6">
<ManualRssUrlAddRow onUrlAdded={refreshManualWebUrls} />
@ -880,16 +970,63 @@ export default function RssFeedList() { @@ -880,16 +970,63 @@ export default function RssFeedList() {
})}
</p>
</div>
<div className="relative w-full max-w-xl">
<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" />
<Input
type="search"
placeholder={t('Search...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-full pl-8 text-xs sm:h-9 sm:pl-9 sm:text-sm"
aria-label={t('Search...')}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="relative w-full max-w-xl flex-1 min-w-0">
<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" />
<Input
type="search"
placeholder={t('Search...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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>
@ -954,20 +1091,6 @@ export default function RssFeedList() { @@ -954,20 +1091,6 @@ export default function RssFeedList() {
</div>
</PopoverContent>
</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>
)}

6
src/components/Settings/SettingsMenuBody.tsx

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

4
src/i18n/locales/ar.ts

@ -1046,6 +1046,10 @@ export default { @@ -1046,6 +1046,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/de.ts

@ -1081,6 +1081,10 @@ export default { @@ -1081,6 +1081,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

27
src/i18n/locales/en.ts

@ -1059,6 +1059,10 @@ export default { @@ -1059,6 +1059,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',
@ -1570,6 +1574,29 @@ export default { @@ -1570,6 +1574,29 @@ export default {
standardRssFeed_medium: 'Medium',
'RSS Feed Settings': 'RSS Feed Settings',
'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':
'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',

4
src/i18n/locales/es.ts

@ -1054,6 +1054,10 @@ export default { @@ -1054,6 +1054,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/fa.ts

@ -1050,6 +1050,10 @@ export default { @@ -1050,6 +1050,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/fr.ts

@ -1059,6 +1059,10 @@ export default { @@ -1059,6 +1059,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/hi.ts

@ -1052,6 +1052,10 @@ export default { @@ -1052,6 +1052,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/it.ts

@ -1055,6 +1055,10 @@ export default { @@ -1055,6 +1055,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/ja.ts

@ -1050,6 +1050,10 @@ export default { @@ -1050,6 +1050,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/ko.ts

@ -1048,6 +1048,10 @@ export default { @@ -1048,6 +1048,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/pl.ts

@ -1053,6 +1053,10 @@ export default { @@ -1053,6 +1053,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

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

@ -1052,6 +1052,10 @@ export default { @@ -1052,6 +1052,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

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

@ -1054,6 +1054,10 @@ export default { @@ -1054,6 +1054,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/ru.ts

@ -1055,6 +1055,10 @@ export default { @@ -1055,6 +1055,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

4
src/i18n/locales/th.ts

@ -1045,6 +1045,10 @@ export default { @@ -1045,6 +1045,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',

23
src/i18n/locales/zh.ts

@ -1040,6 +1040,10 @@ export default { @@ -1040,6 +1040,10 @@ export default {
'Last day': 'Last day',
'Last week': 'Last week',
'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',
'Search...': 'Search...',
'{{count}} feeds': '{{count}} feeds',
@ -1551,6 +1555,25 @@ export default { @@ -1551,6 +1555,25 @@ export default {
standardRssFeed_medium: 'Medium',
'RSS Feed Settings': 'RSS Feed Settings',
'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':
'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',

41
src/lib/draft-event.ts

@ -31,6 +31,7 @@ import { @@ -31,6 +31,7 @@ import {
NIP22_URL_SCOPE_KIND
} from '@/lib/rss-article'
import { cleanUrl } from '@/lib/url'
import { urlToWebBookmarkDTag } from '@/lib/web-bookmark-nip'
import { randomString } from './random'
import { generateBech32IdFromETag, tagNameEquals } from './tag'
@ -902,6 +903,46 @@ export function createBookmarkDraftEvent(tags: string[][], content = ''): TDraft @@ -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 {
return {
kind: 10015,

1
src/lib/link.ts

@ -73,6 +73,7 @@ export const toTranslation = () => '/settings/translation' @@ -73,6 +73,7 @@ export const toTranslation = () => '/settings/translation'
export const toRssFeedSettings = () => '/settings/rss-feeds'
export const toFollowSetsSettings = () => '/settings/follow-sets'
export const toCacheSettings = () => '/settings/cache'
export const toPersonalListsSettings = () => '/settings/personal-lists'
export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
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>([ @@ -19,7 +19,8 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY,
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 { @@ -22,6 +22,10 @@ function relayLabel(url: string): string {
/** User-visible result after the relay responds to NIP-42 AUTH (`OK` / failure). */
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 detailSuffix = okReason?.trim() ? ` (${okReason.trim()})` : ''
toast.success(

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

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

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

@ -194,3 +194,19 @@ export function buildBookmarksSubRequests(bookmarkListEvent: Event | null, urls: @@ -194,3 +194,19 @@ export function buildBookmarksSubRequests(bookmarkListEvent: Event | null, urls:
const slice = ids.slice(0, cap)
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 { @@ -104,6 +104,7 @@ import {
appendCuratedReadOnlyRelays,
applyFauxSpellCapsToSubRequests,
buildBookmarksSubRequests,
buildWebBookmarksSpellSubRequests,
buildCalendarSpellFilter,
buildDiscussionFilter,
buildInterestsSubRequests,
@ -868,7 +869,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -868,7 +869,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'bookmarks') {
if (!pubkey) return []
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') {
const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays)
@ -1095,7 +1098,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1095,7 +1098,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
return [...DEFAULT_FEED_SHOW_KINDS]
}
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]
const kinds = selectedSpell.tags
@ -1206,7 +1211,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1206,7 +1211,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const fauxNoteListUseFilterAsIs = useMemo(() => {
if (!selectedFauxSpell) return true
if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false
return selectedFauxSpell !== 'bookmarks'
return true
}, [selectedFauxSpell])
const notificationsMentionExtraHide = useCallback(
@ -1218,7 +1223,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1218,7 +1223,8 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
const fauxFeedEmptyMessage = useMemo(() => {
if (!selectedFauxSpell || fauxSubRequests.length > 0) return null
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 (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty')
return t('Nothing to load for this feed.')
@ -1699,7 +1705,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1699,7 +1705,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
? NOTIFICATION_SPELL_LOADING_SAFETY_MS
: undefined
}
clientSideKindFilter={selectedFauxSpell === 'notifications'}
clientSideKindFilter={
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks'
}
useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false}
showKind1OPs={

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

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

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

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

2
src/routes.tsx

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

33
src/services/navigation.service.ts

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

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

@ -1702,6 +1702,61 @@ class RssFeedService { @@ -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
*/

Loading…
Cancel
Save