28 changed files with 1045 additions and 233 deletions
@ -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 @@ |
|||||||
|
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