28 changed files with 1045 additions and 233 deletions
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
import { ExtendedKind } from '@/constants' |
||||
import { cleanUrl } from '@/lib/url' |
||||
import { bytesToHex } from '@noble/hashes/utils' |
||||
import { sha256 } from '@noble/hashes/sha256' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** Encode article URL for a single path segment (UTF-8 → base64url, no padding). */ |
||||
export function encodeRssArticlePathSegment(articleUrl: string): string { |
||||
const bytes = new TextEncoder().encode(articleUrl) |
||||
let binary = '' |
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!) |
||||
const b64 = btoa(binary) |
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') |
||||
} |
||||
|
||||
export function decodeRssArticlePathSegment(segment: string): string { |
||||
const b64 = segment.replace(/-/g, '+').replace(/_/g, '/') |
||||
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)) |
||||
const binary = atob(b64 + pad) |
||||
const out = new Uint8Array(binary.length) |
||||
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) |
||||
return new TextDecoder().decode(out) |
||||
} |
||||
|
||||
/** Stable fake event id for caching / stats keys (not a published note id). */ |
||||
export function rssArticleStableEventId(articleUrl: string): string { |
||||
return bytesToHex(sha256(new TextEncoder().encode(`rss-thread-root:${articleUrl}`))) |
||||
} |
||||
|
||||
/** Strip tracking params from http(s) article URLs; leave other values unchanged. */ |
||||
export function canonicalizeRssArticleUrl(url: string): string { |
||||
const t = url.trim() |
||||
if (!t.startsWith('http://') && !t.startsWith('https://')) return t |
||||
return cleanUrl(t) || t |
||||
} |
||||
|
||||
/** Normalize user input to an http(s) URL for manual article threads; returns null if invalid. */ |
||||
export function normalizeHttpArticleUrl(raw: string): string | null { |
||||
let s = raw.trim() |
||||
if (!s) return null |
||||
if (!/^https?:\/\//i.test(s)) { |
||||
s = `https://${s}` |
||||
} |
||||
try { |
||||
const u = new URL(s) |
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null |
||||
return canonicalizeRssArticleUrl(u.href) |
||||
} catch { |
||||
return null |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Synthetic parent event for kind 1111 comments on an RSS article. |
||||
* Thread is keyed by the article URL in both `i` and `I` tags (no e/a root). |
||||
*/ |
||||
export function createRssThreadRootEvent(articleUrl: string): Event { |
||||
const canonical = canonicalizeRssArticleUrl(articleUrl) |
||||
return { |
||||
id: rssArticleStableEventId(canonical), |
||||
pubkey: '0'.repeat(64), |
||||
created_at: 0, |
||||
kind: ExtendedKind.RSS_THREAD_ROOT, |
||||
tags: [ |
||||
['i', canonical], |
||||
['I', canonical] |
||||
], |
||||
content: '', |
||||
sig: '' |
||||
} |
||||
} |
||||
|
||||
export function getArticleUrlFromCommentITags(event: Event): string | undefined { |
||||
const upper = event.tags.find((t) => t[0] === 'I')?.[1] |
||||
if (upper) return upper |
||||
return event.tags.find((t) => t[0] === 'i')?.[1] |
||||
} |
||||
|
||||
/** Client-only RSS thread parent (non-standard kind); not a real relay event. */ |
||||
export function isRssThreadSyntheticParentEvent(event: Pick<Event, 'kind'>): boolean { |
||||
return event.kind === ExtendedKind.RSS_THREAD_ROOT |
||||
} |
||||
@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
|
||||
import NoteInteractions from '@/components/NoteInteractions' |
||||
import NoteStats from '@/components/NoteStats' |
||||
import RssFeedItem from '@/components/RssFeedItem' |
||||
import WebPreview from '@/components/WebPreview' |
||||
import { Separator } from '@/components/ui/separator' |
||||
import indexedDb from '@/services/indexed-db.service' |
||||
import type { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { decodeRssArticlePathSegment, createRssThreadRootEvent } from '@/lib/rss-article' |
||||
import { forwardRef, useEffect, useMemo, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { ExternalLink } from 'lucide-react' |
||||
import { Button } from '@/components/ui/button' |
||||
|
||||
const RssArticlePage = forwardRef( |
||||
( |
||||
{ |
||||
articleKey, |
||||
index, |
||||
hideTitlebar = false, |
||||
initialItem |
||||
}: { |
||||
articleKey: string |
||||
index?: number |
||||
hideTitlebar?: boolean |
||||
initialItem?: TRssFeedItem |
||||
}, |
||||
ref |
||||
) => { |
||||
const { t } = useTranslation() |
||||
const [item, setItem] = useState<TRssFeedItem | null>(initialItem ?? null) |
||||
const [loading, setLoading] = useState(!initialItem) |
||||
|
||||
const articleUrl = useMemo(() => { |
||||
try { |
||||
return decodeRssArticlePathSegment(articleKey) |
||||
} catch { |
||||
return '' |
||||
} |
||||
}, [articleKey]) |
||||
|
||||
useEffect(() => { |
||||
if (initialItem || !articleUrl) { |
||||
setLoading(false) |
||||
return |
||||
} |
||||
let cancelled = false |
||||
;(async () => { |
||||
try { |
||||
const items = await indexedDb.getRssFeedItems() |
||||
if (cancelled) return |
||||
const found = items.find((i) => i.link === articleUrl) ?? null |
||||
setItem(found) |
||||
} finally { |
||||
if (!cancelled) setLoading(false) |
||||
} |
||||
})() |
||||
return () => { |
||||
cancelled = true |
||||
} |
||||
}, [articleUrl, initialItem]) |
||||
|
||||
const syntheticRoot = useMemo( |
||||
() => (articleUrl ? createRssThreadRootEvent(articleUrl) : null), |
||||
[articleUrl] |
||||
) |
||||
|
||||
useEffect(() => { |
||||
if (hideTitlebar) { |
||||
sessionStorage.setItem('notePageTitle', item ? t('RSS article') : t('Web page')) |
||||
} |
||||
return () => { |
||||
if (hideTitlebar) { |
||||
sessionStorage.removeItem('notePageTitle') |
||||
} |
||||
} |
||||
}, [hideTitlebar, t, item]) |
||||
|
||||
if (!articleUrl) { |
||||
return ( |
||||
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS article')}> |
||||
<div className="px-4 py-6 text-sm text-muted-foreground">{t('Invalid article link.')}</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
|
||||
if (loading) { |
||||
return ( |
||||
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('RSS article')}> |
||||
<div className="px-4 py-6 text-sm text-muted-foreground">{t('Loading…')}</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
|
||||
if (!item) { |
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={hideTitlebar ? undefined : t('Web page')} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="px-4 pt-3 pb-4 w-full space-y-4"> |
||||
<p className="text-xs text-muted-foreground"> |
||||
{t('Opened by URL — not from your RSS list. Nostr thread is still tied to this link.')} |
||||
</p> |
||||
<div className="not-prose max-w-full"> |
||||
<WebPreview url={articleUrl} className="w-full" /> |
||||
</div> |
||||
<Button variant="outline" size="sm" asChild> |
||||
<a href={articleUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-2"> |
||||
{t('Open in browser')} |
||||
<ExternalLink className="h-3.5 w-3.5" /> |
||||
</a> |
||||
</Button> |
||||
{syntheticRoot && ( |
||||
<div className="px-0 w-full"> |
||||
<NoteStats className="mt-2" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} /> |
||||
</div> |
||||
)} |
||||
<Separator /> |
||||
<div className="w-full"> |
||||
{syntheticRoot && ( |
||||
<NoteInteractions |
||||
key={`rss-interactions-${syntheticRoot.id}`} |
||||
pageIndex={index} |
||||
event={syntheticRoot} |
||||
showQuotes={false} |
||||
/> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout |
||||
ref={ref} |
||||
index={index} |
||||
title={hideTitlebar ? undefined : t('RSS article')} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="px-4 pt-3 w-full"> |
||||
<RssFeedItem item={item} layout="detail" /> |
||||
</div> |
||||
{syntheticRoot && ( |
||||
<div className="px-4 w-full"> |
||||
<NoteStats className="mt-3" event={syntheticRoot} fetchIfNotExisting={false} displayTopZapsAndLikes={false} /> |
||||
</div> |
||||
)} |
||||
<Separator className="mt-4" /> |
||||
<div className="px-4 pb-4 w-full"> |
||||
{syntheticRoot && ( |
||||
<NoteInteractions |
||||
key={`rss-interactions-${syntheticRoot.id}`} |
||||
pageIndex={index} |
||||
event={syntheticRoot} |
||||
showQuotes={false} |
||||
/> |
||||
)} |
||||
</div> |
||||
</SecondaryPageLayout> |
||||
) |
||||
} |
||||
) |
||||
|
||||
RssArticlePage.displayName = 'RssArticlePage' |
||||
export default RssArticlePage |
||||
Loading…
Reference in new issue