You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

639 lines
23 KiB

import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { isReplyNoteEvent } from '@/lib/event'
import {
articleUrlMatchesThreadScope,
canonicalizeRssArticleUrl,
expandArticleUrlThreadQueryValues,
getArticleUrlFromCommentITags,
getHighlightSourceHttpUrl,
getReactionPageUrlFromRTags,
getWebBookmarkArticleUrl,
getWebExternalReactionTargetUrl
} from '@/lib/rss-article'
import logger from '@/lib/logger'
import { isImage, isLocalNetworkUrl, isMedia, isVideo, normalizeUrl } from '@/lib/url'
import { eventService, queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { RssFeedItem } from '@/services/rss-feed.service'
import { isWebOnlyFauxRssItem } from '@/services/rss-feed.service'
import { kinds, type Event, type Filter } from 'nostr-tools'
/** IndexedDB: `'1'` (default) = hide clawstr.com (strip preview links + drop URL/RSS rows for that host). */
export const RSS_WEB_SUPPRESS_CLAWSTR_SETTING = 'rssWebSuppressClawstrLinks'
/** IndexedDB: `'1'` (default) = keep local/media/feed XML links as plain RSS rows, not URL cards. */
export const RSS_WEB_HIDE_UNIFIED_CLUTTER_SETTING = 'rssWebHideUnifiedClutter'
/** IndexedDB: feed view — URLs (no feed items) vs RSS (feed-backed rows). */
export const RSS_WEB_FEED_SCOPE_SETTING = 'rssWebFeedScope'
/** IndexedDB: JSON array of `{ url, addedAt }` for URLs added from “Add URL” (no RSS row yet). */
export const RSS_WEB_MANUAL_URLS_SETTING = 'rssWebManualUrls'
/**
* `urls` = article URL cards with no real subscribed-feed items (Nostr/manual / web preview only).
* `rss` = feed items, non-HTTP entries, and URL cards that include at least one real RSS item.
*/
export type RssWebFeedScope = 'urls' | 'rss'
/** True if the row includes at least one item from a subscribed RSS feed (not the synthetic web-only row). */
export function rssWebRowHasRealFeedItems(
items: Pick<RssFeedItem, 'feedUrl' | 'guid'>[]
): boolean {
return items.some((i) => !isWebOnlyFauxRssItem(i))
}
/** Normalize stored scope (legacy `both` / `webAndRss` → `rss`). */
export function parseRssWebFeedScope(raw: string | null | undefined): RssWebFeedScope {
if (raw === 'urls' || raw === 'rss') return raw
if (raw === 'both' || raw === 'webAndRss' || raw === 'all') return 'rss'
if (raw === 'webOnly') return 'urls'
if (raw === 'rssOnly') return 'rss'
return 'urls'
}
export type ManualRssWebUrlEntry = { url: string; addedAt: number }
const MAX_MANUAL_WEB_URLS = 200
/** Keep newest URLs by `addedAt`; drops oldest when over limit. */
function trimManualRssWebUrlsToLimit(entries: ManualRssWebUrlEntry[]): ManualRssWebUrlEntry[] {
if (entries.length <= MAX_MANUAL_WEB_URLS) return entries
return [...entries]
.sort((a, b) => b.addedAt - a.addedAt)
.slice(0, MAX_MANUAL_WEB_URLS)
}
/** Per-kind REQ limit for RSS+Web relay URL discovery (no `authors` filter). */
export const RSS_WEB_NOSTR_PER_KIND_LIMIT = 100
/** Relay discovery: only events in this window (some relays reject unbounded kind-only REQs). */
const RSS_WEB_RELAY_DISCOVERY_SINCE_SEC = 365 * 24 * 60 * 60
export async function loadManualRssWebUrls(): Promise<ManualRssWebUrlEntry[]> {
const raw = await indexedDb.getSetting(RSS_WEB_MANUAL_URLS_SETTING)
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
const out: ManualRssWebUrlEntry[] = []
for (const x of parsed) {
if (typeof x !== 'object' || x === null) continue
const rec = x as Record<string, unknown>
if (typeof rec.url !== 'string') continue
const url = canonicalizeRssArticleUrl(rec.url.trim())
if (!isHttpArticleUrl(url)) continue
const addedAt = typeof rec.addedAt === 'number' ? rec.addedAt : 0
out.push({ url, addedAt })
}
return out
} catch {
return []
}
}
/** Dedupes by canonical URL; newest first. Returns canonical URL. */
export async function addManualRssWebUrl(rawUrl: string): Promise<string> {
const canonical = canonicalizeRssArticleUrl(rawUrl.trim())
if (!isHttpArticleUrl(canonical)) return canonical
const existing = await loadManualRssWebUrls()
const filtered = existing.filter((e) => e.url !== canonical)
const next = trimManualRssWebUrlsToLimit([
{ url: canonical, addedAt: Date.now() },
...filtered
])
await indexedDb.setSetting(RSS_WEB_MANUAL_URLS_SETTING, JSON.stringify(next))
return canonical
}
/**
* Merge URLs learned from Nostr (follows + self) into the manual web URL list.
* Returns whether IndexedDB was updated (caller may refetch UI state).
*/
export async function mergeDiscoveredRssWebUrls(discovered: ManualRssWebUrlEntry[]): Promise<boolean> {
if (discovered.length === 0) return false
const existing = await loadManualRssWebUrls()
const byUrl = new Map<string, number>()
for (const e of existing) {
byUrl.set(e.url, e.addedAt)
}
let changed = false
for (const d of discovered) {
const prev = byUrl.get(d.url) ?? 0
const next = Math.max(prev, d.addedAt)
if (next !== prev) changed = true
byUrl.set(d.url, next)
}
if (!changed) return false
const merged = trimManualRssWebUrlsToLimit(
[...byUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
)
await indexedDb.setSetting(RSS_WEB_MANUAL_URLS_SETTING, JSON.stringify(merged))
return true
}
/** Dispatched after publishing a kind 17 web URL reaction so RSS+Web can refetch. */
export const WEB_EXTERNAL_REACTION_PUBLISHED_EVENT = 'jumble:webExternalReactionPublished'
/** IndexedDB: JSON array of canonical article URLs promoted from RSS read-only into the URL feed (Nostr thread). */
export const RSS_WEB_PROMOTED_THREAD_URLS_SETTING = 'rssWebPromotedThreadUrls'
const MAX_PROMOTED_THREAD_URLS = 300
export async function loadPromotedRssThreadUrls(): Promise<string[]> {
const raw = await indexedDb.getSetting(RSS_WEB_PROMOTED_THREAD_URLS_SETTING)
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
const out: string[] = []
for (const x of parsed) {
if (typeof x !== 'string') continue
const c = canonicalizeRssArticleUrl(x.trim())
if (isHttpArticleUrl(c)) out.push(c)
}
return [...new Set(out)].slice(0, MAX_PROMOTED_THREAD_URLS)
} catch {
return []
}
}
export async function addPromotedRssThreadUrl(rawUrl: string): Promise<string> {
const canonical = canonicalizeRssArticleUrl(rawUrl.trim())
if (!isHttpArticleUrl(canonical)) return canonical
const existing = await loadPromotedRssThreadUrls()
const filtered = existing.filter((u) => u !== canonical)
const next = [canonical, ...filtered].slice(0, MAX_PROMOTED_THREAD_URLS)
await indexedDb.setSetting(RSS_WEB_PROMOTED_THREAD_URLS_SETTING, JSON.stringify(next))
return canonical
}
/**
* Adds the URL to the manual web list, marks it for the URL feed with full Nostr thread UI,
* and dispatches {@link WEB_EXTERNAL_REACTION_PUBLISHED_EVENT}.
*/
export async function promoteRssArticleForNostrThread(rawUrl: string): Promise<string> {
const canonical = await addManualRssWebUrl(rawUrl)
if (!isHttpArticleUrl(canonical)) return canonical
await addPromotedRssThreadUrl(canonical)
window.dispatchEvent(new CustomEvent(WEB_EXTERNAL_REACTION_PUBLISHED_EVENT))
return canonical
}
export type RssUrlGroup = {
canonicalUrl: string
items: RssFeedItem[]
/** Latest RSS pubDate in group for sorting */
latestPub: number
}
export function isHttpArticleUrl(url: string): boolean {
const t = url.trim()
return t.startsWith('http://') || t.startsWith('https://')
}
/**
* URLs that make poor “article URL” cards: localhost/LAN, direct media files, and common RSS/Atom document paths.
* When filtering is on, these stay as normal RSS timeline rows instead of Web URL cards.
*/
export function isRssWebUnifiedClutterUrl(url: string): boolean {
const t = url.trim()
if (!isHttpArticleUrl(t)) return false
let parsed: URL
try {
parsed = new URL(t)
} catch {
return false
}
const host = parsed.hostname.toLowerCase()
if (host.endsWith('.local')) return true
if (isLocalNetworkUrl(t)) return true
const ipv4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)
if (ipv4 && Number(ipv4[1]) === 127) return true
if (isMedia(t) || isVideo(t) || isImage(t)) return true
const path = parsed.pathname.toLowerCase()
const segments = path.split('/').filter(Boolean)
const last = segments[segments.length - 1] || ''
// Documents — not article pages
if (
/\.(pdf|epub|mobi|azw3|doc|docx|xls|xlsx|ppt|pptx|ods|odt|rtf)(\?.*)?$/i.test(path)
) {
return true
}
if (/\.(rss|atom)$/i.test(last)) return true
if (last === 'feed.xml' || last === 'rss.xml' || last === 'atom.xml') return true
if (last.endsWith('.xml')) return true
if (last === 'feed' || last === 'rss' || last === 'atom') return true
return false
}
/**
* Split filters: `social` uses kinds that match {@link relayFilterIncludesSocialKindBlockedKind} and therefore omit
* {@link SOCIAL_KIND_BLOCKED_RELAY_URLS}; `nonSocial` keeps reactions / `#r` on batches that do not apply that strip.
* Read-only index relays ({@link READ_ONLY_RELAY_URLS}) are unrelated to the social-kind block list.
*/
export function buildRssArticleUrlThreadInteractionFilterGroups(
canonicalArticleUrl: string,
limit: number
): { nonSocial: Filter[]; social: Filter[] } {
const canonical = canonicalizeRssArticleUrl(canonicalArticleUrl)
const tagVals = expandArticleUrlThreadQueryValues(canonical)
const iFilterVals = tagVals.length > 0 ? tagVals : [canonical]
const social: Filter[] = [
{ '#i': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit },
{ '#I': iFilterVals, kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit }
]
const nonSocial: Filter[] = [
{ '#i': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit },
{ '#I': iFilterVals, kinds: [ExtendedKind.EXTERNAL_REACTION], limit }
]
if (tagVals.length > 0) {
nonSocial.push(
{ '#r': tagVals, kinds: [kinds.Highlights], limit },
{ '#r': tagVals, kinds: [kinds.Reaction], limit }
)
}
return { nonSocial, social }
}
/** REQ filters for Nostr comments, reactions, and highlights on one article URL (synthetic RSS thread). */
export function buildRssArticleUrlThreadInteractionFilters(
canonicalArticleUrl: string,
limit: number
): Filter[] {
const { nonSocial, social } = buildRssArticleUrlThreadInteractionFilterGroups(
canonicalArticleUrl,
limit
)
return [...nonSocial, ...social]
}
/** Whether `evt` belongs to the URL-scoped article thread (comments / voice / highlight / reactions on this page). */
export function isRssArticleUrlThreadInteraction(evt: Event, canonicalArticleUrl: string): boolean {
const key = canonicalizeRssArticleUrl(canonicalArticleUrl)
if (evt.kind === kinds.Highlights) {
const hu = getHighlightSourceHttpUrl(evt)
return !!hu && articleUrlMatchesThreadScope(hu, key)
}
if (evt.kind === ExtendedKind.EXTERNAL_REACTION) {
const u = getWebExternalReactionTargetUrl(evt)
return !!u && articleUrlMatchesThreadScope(u, key)
}
if (evt.kind === kinds.Reaction) {
const u = getReactionPageUrlFromRTags(evt)
return !!u && articleUrlMatchesThreadScope(u, key)
}
if (!isReplyNoteEvent(evt)) return false
const u = getArticleUrlFromCommentITags(evt)
return !!u && articleUrlMatchesThreadScope(u, key)
}
/**
* Group RSS entries by canonical article URL (NIP-22 / web thread key).
*/
export function groupRssItemsByCanonicalUrl(items: RssFeedItem[]): RssUrlGroup[] {
const { groups } = partitionRssItemsForWebFeed(items, { excludeClutterLinks: true })
return groups
}
/** HTTP(S) article groups for combined cards; everything else stays as plain RSS rows. */
export function partitionRssItemsForWebFeed(
items: RssFeedItem[],
options?: { excludeClutterLinks?: boolean }
): {
groups: RssUrlGroup[]
nonHttpItems: RssFeedItem[]
} {
const excludeClutter = options?.excludeClutterLinks !== false
const map = new Map<string, RssFeedItem[]>()
const nonHttpItems: RssFeedItem[] = []
for (const item of items) {
const link = item.link?.trim()
if (!link || !isHttpArticleUrl(link)) {
nonHttpItems.push(item)
continue
}
if (excludeClutter && isRssWebUnifiedClutterUrl(link)) {
nonHttpItems.push(item)
continue
}
const key = canonicalizeRssArticleUrl(link)
const list = map.get(key)
if (list) list.push(item)
else map.set(key, [item])
}
const groups: RssUrlGroup[] = []
for (const [canonicalUrl, groupItems] of map) {
let latestPub = 0
for (const it of groupItems) {
const t = it.pubDate?.getTime() ?? 0
if (t > latestPub) latestPub = t
}
groups.push({ canonicalUrl, items: groupItems, latestPub })
}
groups.sort((a, b) => b.latestPub - a.latestPub)
return { groups, nonHttpItems }
}
/**
* One row per article URL for the “web” side of RSS+Web.
*
* **Sources (Nostr-first):** URLs from relay discovery (`relayDiscoveredEntries`) and persisted manual
* URLs (`manualEntries`) are merged in so each becomes a card even when RSS has no row for that URL
* — {@link RssWebFeedCard} then uses a faux RSS item / preview for empty `rssItems`.
*
* **RSS** only *enriches* rows: items from feeds are grouped by canonical link; Nostr-only URLs keep
* `rssItems: []`.
*/
export type ArticleUrlFeedWebRow = {
kind: 'web'
canonicalUrl: string
rssItems: RssFeedItem[]
latestPub: number
}
export function buildArticleUrlFeedRows(
filteredItems: RssFeedItem[],
manualEntries: ManualRssWebUrlEntry[],
relayDiscoveredEntries: ManualRssWebUrlEntry[],
options?: { excludeClutterLinks?: boolean }
): { webRows: ArticleUrlFeedWebRow[]; nonHttpItems: RssFeedItem[] } {
const { groups, nonHttpItems } = partitionRssItemsForWebFeed(filteredItems, options)
const excludeClutter = options?.excludeClutterLinks !== false
const webByUrl = new Map<string, { rssItems: RssFeedItem[]; latestPub: number }>()
for (const g of groups) {
webByUrl.set(g.canonicalUrl, {
rssItems: g.items,
latestPub: g.latestPub
})
}
const mergeNostrTimestamp = (url: string, ts: number) => {
const cur = webByUrl.get(url)
if (cur) {
webByUrl.set(url, {
...cur,
latestPub: Math.max(cur.latestPub, ts)
})
} else {
webByUrl.set(url, { rssItems: [], latestPub: ts })
}
}
for (const { url, addedAt } of manualEntries) {
if (!isHttpArticleUrl(url)) continue
if (excludeClutter && isRssWebUnifiedClutterUrl(url)) continue
mergeNostrTimestamp(canonicalizeRssArticleUrl(url), addedAt)
}
for (const { url, addedAt } of relayDiscoveredEntries) {
if (!isHttpArticleUrl(url)) continue
if (excludeClutter && isRssWebUnifiedClutterUrl(url)) continue
mergeNostrTimestamp(canonicalizeRssArticleUrl(url), addedAt)
}
const webRows: ArticleUrlFeedWebRow[] = Array.from(webByUrl.entries()).map(
([canonicalUrl, v]) => ({
kind: 'web' as const,
canonicalUrl,
rssItems: v.rssItems,
latestPub: v.latestPub
})
)
webRows.sort((a, b) => b.latestPub - a.latestPub)
return { webRows, nonHttpItems }
}
function highlightSourceUrl(evt: Event): string | undefined {
const u = getHighlightSourceHttpUrl(evt)
return u && isHttpArticleUrl(u) ? u : undefined
}
function dedupeRelayUrlsForRssWeb(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const n = normalizeUrl(u) || u
if (!n || seen.has(n)) continue
seen.add(n)
out.push(n)
}
return out
}
/**
* Inbox + favorites + fast read: one normalized list for RSS+Web relay queries.
* Logged-out users get favorites tier + fast read only.
*/
export async function buildRssWebNostrQueryRelayUrls(options: {
accountPubkey: string | null
favoriteRelays: string[]
blockedRelays: string[]
}): Promise<string[]> {
const { accountPubkey, favoriteRelays, blockedRelays } = options
const inboxAndFavorites: string[] = accountPubkey
? await buildAccountListRelayUrlsForMerge({
accountPubkey,
favoriteRelays,
blockedRelays
})
: getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
return dedupeRelayUrlsForRssWeb([...inboxAndFavorites, ...FAST_READ_RELAY_URLS])
}
/** One REQ per kind in {@link fetchDiscoveredWebUrlsFromRelays} (includes kind 7 with page `r` tags). */
const RSS_WEB_RELAY_DISCOVERY_KINDS: number[] = [
ExtendedKind.COMMENT,
ExtendedKind.EXTERNAL_REACTION,
kinds.Highlights,
kinds.Reaction,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.WEB_BOOKMARK
]
function extractArticleUrlFromWebActivityEvent(evt: Event): string | undefined {
if (evt.kind === ExtendedKind.COMMENT || evt.kind === ExtendedKind.VOICE_COMMENT) {
const u = getArticleUrlFromCommentITags(evt)
if (!u || !isHttpArticleUrl(u)) return undefined
return canonicalizeRssArticleUrl(u)
}
if (evt.kind === kinds.Reaction) {
const u = getReactionPageUrlFromRTags(evt)
return u && isHttpArticleUrl(u) ? canonicalizeRssArticleUrl(u) : undefined
}
if (evt.kind === ExtendedKind.EXTERNAL_REACTION) {
const u = getWebExternalReactionTargetUrl(evt)
return u && isHttpArticleUrl(u) ? canonicalizeRssArticleUrl(u) : undefined
}
if (evt.kind === kinds.Highlights) {
return highlightSourceUrl(evt)
}
if (evt.kind === ExtendedKind.WEB_BOOKMARK) {
const u = getWebBookmarkArticleUrl(evt)
return u ? canonicalizeRssArticleUrl(u) : undefined
}
return undefined
}
function touchRssWebDiscoveryUrlFromEvent(
evt: Event,
excludeClutter: boolean,
latestByUrl: Map<string, number>
): void {
const url = extractArticleUrlFromWebActivityEvent(evt)
if (!url) return
if (excludeClutter && isRssWebUnifiedClutterUrl(url)) return
const key = canonicalizeRssArticleUrl(url)
const prev = latestByUrl.get(key) ?? 0
if (evt.created_at > prev) latestByUrl.set(key, evt.created_at)
}
/** Merge manual / discovered URL lists; per URL keep the newest `addedAt`. */
export function mergeManualRssWebUrlEntries(...parts: ManualRssWebUrlEntry[][]): ManualRssWebUrlEntry[] {
const byUrl = new Map<string, number>()
for (const list of parts) {
for (const e of list) {
const prev = byUrl.get(e.url) ?? 0
if (e.addedAt > prev) byUrl.set(e.url, e.addedAt)
}
}
return [...byUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
}
/**
* Article URLs from session LRU + event archive (same kinds as relay discovery), so the URL tab is not
* empty when relays return nothing but the client already saw reactions / bookmarks / etc.
*/
export async function discoverRssWebArticleUrlsFromLocalCaches(options?: {
excludeClutterUrls?: boolean
}): Promise<ManualRssWebUrlEntry[]> {
const excludeClutter = options?.excludeClutterUrls !== false
const sinceSec = Math.floor(Date.now() / 1000) - RSS_WEB_RELAY_DISCOVERY_SINCE_SEC
const latestByUrl = new Map<string, number>()
const sessionEv = eventService.listSessionEventsByKinds(RSS_WEB_RELAY_DISCOVERY_KINDS, {
since: sinceSec,
limit: 5000
})
for (const evt of sessionEv) {
touchRssWebDiscoveryUrlFromEvent(evt, excludeClutter, latestByUrl)
}
try {
const archived = await indexedDb.scanEventArchiveByKinds({
kinds: RSS_WEB_RELAY_DISCOVERY_KINDS,
since: sinceSec,
maxRowsScanned: 24_000,
maxMatches: 4000
})
for (const evt of archived) {
touchRssWebDiscoveryUrlFromEvent(evt, excludeClutter, latestByUrl)
}
} catch {
/* IDB unavailable */
}
const entries = [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
logger.info('[RssWebFeed] Local URL discovery finished', {
uniqueUrls: entries.length,
sessionHits: sessionEv.length
})
return entries
}
/**
* One REQ per kind, no `authors` filter: latest events from aggregated relays, grouped by canonical URL.
*/
export async function fetchDiscoveredWebUrlsFromRelays(options: {
accountPubkey: string | null
favoriteRelays: string[]
blockedRelays: string[]
/** When true (default), omit localhost, media files, and feed-document URLs from discovery. */
excludeClutterUrls?: boolean
}): Promise<ManualRssWebUrlEntry[]> {
const excludeClutter = options.excludeClutterUrls !== false
const relayUrls = await buildRssWebNostrQueryRelayUrls(options)
if (relayUrls.length === 0) {
logger.info('[RssWebFeed] Relay URL discovery skipped (no relays)')
return []
}
logger.info('[RssWebFeed] Relay URL discovery starting', {
relayCount: relayUrls.length,
kinds: RSS_WEB_RELAY_DISCOVERY_KINDS,
perKindLimit: RSS_WEB_NOSTR_PER_KIND_LIMIT
})
const latestByUrl = new Map<string, number>()
const onEvent = (evt: Event) => touchRssWebDiscoveryUrlFromEvent(evt, excludeClutter, latestByUrl)
await Promise.all(
RSS_WEB_RELAY_DISCOVERY_KINDS.map(async (kind) => {
try {
await queryService.fetchEvents(
relayUrls,
[
{
kinds: [kind],
limit: RSS_WEB_NOSTR_PER_KIND_LIMIT,
since: Math.floor(Date.now() / 1000) - RSS_WEB_RELAY_DISCOVERY_SINCE_SEC
}
],
{
onevent: onEvent,
eoseTimeout: 5000,
globalTimeout: 15000
}
)
} catch {
/* per-kind */
}
})
)
const entries = [...latestByUrl.entries()].map(([url, addedAt]) => ({ url, addedAt }))
logger.info('[RssWebFeed] Relay URL discovery finished', {
uniqueUrls: entries.length
})
return entries
}
export async function loadRssWebSuppressClawstrPreference(): Promise<boolean> {
const v = await indexedDb.getSetting(RSS_WEB_SUPPRESS_CLAWSTR_SETTING)
if (v === '0' || v === 'false') return false
if (v === '1' || v === 'true') return true
return true
}
export async function saveRssWebSuppressClawstrPreference(suppress: boolean): Promise<void> {
await indexedDb.setSetting(RSS_WEB_SUPPRESS_CLAWSTR_SETTING, suppress ? '1' : '0')
}
export async function loadRssWebHideUnifiedClutterPreference(): Promise<boolean> {
const v = await indexedDb.getSetting(RSS_WEB_HIDE_UNIFIED_CLUTTER_SETTING)
if (v === '0' || v === 'false') return false
if (v === '1' || v === 'true') return true
return true
}
export async function saveRssWebHideUnifiedClutterPreference(hide: boolean): Promise<void> {
await indexedDb.setSetting(RSS_WEB_HIDE_UNIFIED_CLUTTER_SETTING, hide ? '1' : '0')
}
export async function loadRssWebFeedScopePreference(): Promise<RssWebFeedScope> {
const v = await indexedDb.getSetting(RSS_WEB_FEED_SCOPE_SETTING)
return parseRssWebFeedScope(v)
}
export async function saveRssWebFeedScopePreference(scope: RssWebFeedScope): Promise<void> {
await indexedDb.setSetting(RSS_WEB_FEED_SCOPE_SETTING, scope)
}
export function filterEventsByPubkey(events: Event[], pubkey: string | null | undefined): Event[] {
if (!pubkey) return events
return events.filter((e) => e.pubkey === pubkey)
}