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.
 
 
 
 
 

821 lines
25 KiB

<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { sessionManager } from '../../lib/services/auth/session-manager.js';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../lib/services/nostr/relay-manager.js';
import { config } from '../../lib/services/nostr/config.js';
import { cacheRSSFeed, getCachedRSSFeed, type RSSItem } from '../../lib/services/cache/rss-cache.js';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import MarkdownRenderer from '../../lib/components/content/MarkdownRenderer.svelte';
import RSSCommentForm from '../../lib/modules/rss/RSSCommentForm.svelte';
import IconButton from '../../lib/components/ui/IconButton.svelte';
const RSS_FEED_KIND = 10895;
let currentPubkey = $state<string | null>(null);
let rssEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
let loadingFeeds = $state(false);
let rssItems = $state<RSSItem[]>([]);
let feedErrors = $state<Map<string, string>>(new Map());
let feedsLoaded = $state(false); // Track if feeds have been loaded to prevent re-triggering
let currentPage = $state(1);
const itemsPerPage = 50;
let subscribedFeeds = $derived.by(() => {
if (!rssEvent) return [];
return rssEvent.tags
.filter(tag => tag[0] === 'u' && tag[1])
.map(tag => tag[1]);
});
// Track the last feeds we loaded to detect changes
let lastLoadedFeeds = $state<string[]>([]);
// Computed: get items for current page
let paginatedItems = $derived.by(() => {
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return rssItems.slice(start, end);
});
// Computed: total pages
let totalPages = $derived.by(() => Math.ceil(rssItems.length / itemsPerPage));
onMount(async () => {
await nostrClient.initialize();
const session = sessionManager.getSession();
if (!session) {
goto('/login');
return;
}
currentPubkey = session.pubkey;
await checkRssEvent();
});
$effect(() => {
// Only load if:
// 1. We have feeds to load
// 2. We have an rssEvent
// 3. We're not currently loading
// 4. The feeds have changed (different URLs) or haven't been loaded yet
const feedsChanged = JSON.stringify(subscribedFeeds) !== JSON.stringify(lastLoadedFeeds);
if (subscribedFeeds.length > 0 && rssEvent && !loadingFeeds && (feedsChanged || !feedsLoaded)) {
lastLoadedFeeds = [...subscribedFeeds];
loadRssFeeds();
} else if (subscribedFeeds.length === 0 && feedsLoaded) {
// Reset if feeds were removed
feedsLoaded = false;
rssItems = [];
feedErrors.clear();
lastLoadedFeeds = [];
currentPage = 1;
}
});
// Reset to page 1 when items change significantly
$effect(() => {
if (rssItems.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
async function checkRssEvent() {
if (!currentPubkey) return;
// Load from cache first (fast - instant display)
try {
const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js');
const cachedEvents = await getRecentCachedEvents([RSS_FEED_KIND], 60 * 60 * 1000, 1); // 1 hour cache
const cachedRssEvent = cachedEvents.find(e => e.pubkey === currentPubkey);
if (cachedRssEvent) {
// Only update if it's actually different (prevents unnecessary re-renders)
if (!rssEvent || rssEvent.id !== cachedRssEvent.id) {
rssEvent = cachedRssEvent;
// Reset feeds loaded state when event changes
feedsLoaded = false;
lastLoadedFeeds = [];
}
loading = false; // Show cached content immediately
} else {
loading = true; // Only show loading if no cache
}
} catch (error) {
// Cache error (non-critical)
loading = true; // Show loading if cache check fails
}
try {
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ kinds: [RSS_FEED_KIND], authors: [currentPubkey], limit: 1 }],
relays,
{
useCache: 'cache-first', // Already shown cache above, now stream updates
cacheResults: true,
timeout: config.standardTimeout
}
);
if (events.length > 0) {
const newRssEvent = events[0];
// Only update if it's actually different (prevents unnecessary re-renders)
if (!rssEvent || rssEvent.id !== newRssEvent.id) {
rssEvent = newRssEvent;
// Reset feeds loaded state when event changes
feedsLoaded = false;
lastLoadedFeeds = [];
currentPage = 1; // Reset to first page when event changes
}
}
} catch (error) {
// Failed to load RSS event
} finally {
loading = false;
}
}
async function loadRssFeeds() {
if (subscribedFeeds.length === 0 || loadingFeeds || feedsLoaded) return;
loadingFeeds = true;
feedErrors.clear();
const allItems: RSSItem[] = [];
try {
// First, try to load from cache
const cachePromises = subscribedFeeds.map(async (feedUrl) => {
const cached = await getCachedRSSFeed(feedUrl);
if (cached && cached.length > 0) {
return { feedUrl, items: cached, fromCache: true };
}
return { feedUrl, items: null, fromCache: false };
});
const cacheResults = await Promise.all(cachePromises);
// Add cached items immediately
for (const result of cacheResults) {
if (result.items) {
allItems.push(...result.items);
}
}
// If we have cached items, show them immediately
if (allItems.length > 0) {
allItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
rssItems = allItems;
feedsLoaded = true;
}
// Then fetch fresh data in background for feeds that need updating
const feedsToFetch = cacheResults
.filter(result => !result.fromCache || !result.items)
.map(result => result.feedUrl);
if (feedsToFetch.length > 0) {
const fetchPromises = feedsToFetch.map(async (feedUrl) => {
try {
// Add timeout to prevent hanging requests
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), 10000); // 10 second timeout
});
const items = await Promise.race([
fetchRssFeed(feedUrl),
timeoutPromise
]);
// Cache the fetched items
await cacheRSSFeed(feedUrl, items);
return { feedUrl, items };
} catch (error) {
// Only log non-CORS errors to avoid console spam
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch feed';
// Feed fetch failed (errors logged only for non-CORS/timeout issues)
feedErrors.set(feedUrl, errorMessage);
return { feedUrl, items: [] };
}
});
const fetchResults = await Promise.all(fetchPromises);
// Merge fresh items with cached items
const freshItems: RSSItem[] = [];
for (const result of fetchResults) {
freshItems.push(...result.items);
}
// Combine with existing items and deduplicate by link
const itemMap = new Map<string, RSSItem>();
for (const item of allItems) {
itemMap.set(item.link, item);
}
for (const item of freshItems) {
itemMap.set(item.link, item);
}
// Sort by date (newest first)
const combinedItems = Array.from(itemMap.values());
combinedItems.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
rssItems = combinedItems;
currentPage = 1; // Reset to first page when items update
}
feedsLoaded = true;
} catch (error) {
// Failed to load RSS feeds
} finally {
loadingFeeds = false;
}
}
async function fetchRssFeed(feedUrl: string): Promise<RSSItem[]> {
// Always use a CORS proxy to avoid CORS errors
// Direct fetch will fail for most RSS feeds due to CORS restrictions
const proxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(feedUrl)}`;
const response = await fetch(proxyUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
// Check for parsing errors
const parserError = xmlDoc.querySelector('parsererror');
if (parserError) {
throw new Error('Failed to parse RSS XML');
}
// Get feed title
const feedTitle = xmlDoc.querySelector('channel > title')?.textContent?.trim() ||
xmlDoc.querySelector('title')?.textContent?.trim();
// Parse items (handle both RSS 2.0 and Atom formats)
const items: RSSItem[] = [];
// RSS 2.0 format
const rssItems = xmlDoc.querySelectorAll('item');
rssItems.forEach((item) => {
const title = item.querySelector('title')?.textContent?.trim() || 'Untitled';
const link = item.querySelector('link')?.textContent?.trim() || '';
const description = item.querySelector('description')?.textContent?.trim() || '';
const pubDateStr = item.querySelector('pubDate')?.textContent?.trim() || '';
let pubDate = new Date();
if (pubDateStr) {
const parsed = new Date(pubDateStr);
if (!isNaN(parsed.getTime())) {
pubDate = parsed;
}
}
items.push({
title,
link,
description,
pubDate,
feedUrl,
feedTitle
});
});
// Atom format (if no RSS items found)
if (items.length === 0) {
const atomEntries = xmlDoc.querySelectorAll('entry');
atomEntries.forEach((entry) => {
const title = entry.querySelector('title')?.textContent?.trim() || 'Untitled';
const linkEl = entry.querySelector('link');
const link = linkEl?.getAttribute('href') || linkEl?.textContent?.trim() || '';
const description = entry.querySelector('summary')?.textContent?.trim() ||
entry.querySelector('content')?.textContent?.trim() || '';
const pubDateStr = entry.querySelector('published')?.textContent?.trim() ||
entry.querySelector('updated')?.textContent?.trim() || '';
let pubDate = new Date();
if (pubDateStr) {
const parsed = new Date(pubDateStr);
if (!isNaN(parsed.getTime())) {
pubDate = parsed;
}
}
items.push({
title,
link,
description,
pubDate,
feedUrl,
feedTitle
});
});
}
return items;
}
function getRelativeTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const minutes = Math.floor(diff / (1000 * 60));
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function handleCreateRss() {
goto(`/write?kind=${RSS_FEED_KIND}`);
}
// Track which RSS item has reply form open
let openReplyFormFor = $state<string | null>(null); // URL of the item
function toggleReplyForm(itemLink: string) {
if (openReplyFormFor === itemLink) {
openReplyFormFor = null;
} else {
openReplyFormFor = itemLink;
}
}
// Generate a deterministic thread ID from the URL (for RSS items, we use the URL as threadId)
function getThreadIdFromUrl(url: string): string {
// Use the URL as the threadId for RSS items
return url;
}
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="rss-page">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/RSS</h1>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading...</p>
{:else if !rssEvent}
<div class="rss-setup">
<p class="mb-4 text-fog-text dark:text-fog-dark-text">
You don't have an RSS feed subscription event yet. Create a kind {RSS_FEED_KIND} event to subscribe to external RSS feeds.
</p>
<p class="mb-4 text-sm text-fog-text-light dark:text-fog-dark-text-light">
Add "u" tags with RSS feed URLs to subscribe to external feeds.
</p>
<button
onclick={handleCreateRss}
class="create-rss-button"
>
Create RSS Feed Subscription Event
</button>
</div>
{:else}
<div class="rss-content">
<div class="rss-info">
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">Subscribed RSS Feeds</h2>
{#if subscribedFeeds.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">
No RSS feeds subscribed. Edit your kind {RSS_FEED_KIND} event to add "u" tags with RSS feed URLs.
</p>
{:else}
<ul class="rss-feed-list">
{#each subscribedFeeds as feedUrl}
<li class="rss-feed-item">
<a
href={feedUrl}
target="_blank"
rel="noopener noreferrer"
class="rss-feed-link"
>
{feedUrl}
</a>
{#if feedErrors.has(feedUrl)}
<span class="feed-error">Error: {feedErrors.get(feedUrl)}</span>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="mt-6 pt-6 border-t border-fog-border dark:border-fog-dark-border">
<p class="mb-2 text-sm text-fog-text dark:text-fog-dark-text">
To add or remove feeds, edit your kind {RSS_FEED_KIND} event.
</p>
{#if rssEvent}
<a
href="/write"
class="edit-rss-button"
onclick={(e) => {
if (rssEvent) {
const cloneData = {
kind: rssEvent.kind,
content: rssEvent.content,
tags: rssEvent.tags,
isClone: false
};
sessionStorage.setItem('aitherboard_cloneEvent', JSON.stringify(cloneData));
}
}}
>
Edit RSS Feed Event
</a>
{:else}
<a
href="/write?kind={RSS_FEED_KIND}"
class="edit-rss-button"
>
Create RSS Feed Event
</a>
{/if}
</div>
</div>
{#if subscribedFeeds.length > 0}
<div class="rss-items-section">
<h2 class="text-xl font-semibold mb-4 text-fog-text dark:text-fog-dark-text">RSS Feed Items</h2>
{#if loadingFeeds}
<p class="text-fog-text dark:text-fog-dark-text">Loading RSS feeds...</p>
{:else if rssItems.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No RSS items found.</p>
{:else}
<div class="rss-items-info mb-4">
<p class="text-fog-text dark:text-fog-dark-text text-sm">
Showing {paginatedItems.length} of {rssItems.length} items
</p>
</div>
{#if totalPages > 1}
<div class="pagination pagination-top mb-4">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) currentPage--;
}}
aria-label="Previous page"
>
Previous
</button>
<div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
</div>
<button
class="pagination-button"
disabled={currentPage === totalPages}
onclick={() => {
if (currentPage < totalPages) currentPage++;
}}
aria-label="Next page"
>
Next
</button>
</div>
{/if}
<div class="rss-items">
{#each paginatedItems as item (item.link + item.pubDate.getTime())}
<article class="rss-item">
<div class="rss-item-header">
<div class="rss-item-meta">
{#if item.feedTitle}
<span class="rss-feed-badge">{item.feedTitle}</span>
{/if}
<span class="rss-item-time">{getRelativeTime(item.pubDate)}</span>
</div>
</div>
<h3 class="rss-item-title">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
class="rss-item-link"
>
{item.title}
</a>
</h3>
{#if item.description}
<div class="rss-item-description">
<MarkdownRenderer content={item.description} />
</div>
{/if}
{#if sessionManager.isLoggedIn()}
<div class="rss-item-actions flex items-center gap-2 mt-2">
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => toggleReplyForm(item.link)}
/>
</div>
{/if}
{#if openReplyFormFor === item.link}
<div class="rss-item-reply-form mt-4">
<RSSCommentForm
url={item.link}
onPublished={() => {
openReplyFormFor = null;
}}
onCancel={() => {
openReplyFormFor = null;
}}
/>
</div>
{/if}
</article>
{/each}
</div>
{#if totalPages > 1}
<div class="pagination pagination-bottom mt-4">
<button
class="pagination-button"
disabled={currentPage === 1}
onclick={() => {
if (currentPage > 1) currentPage--;
}}
aria-label="Previous page"
>
Previous
</button>
<div class="pagination-info">
<span class="text-fog-text dark:text-fog-dark-text">
Page {currentPage} of {totalPages}
</span>
</div>
<button
class="pagination-button"
disabled={currentPage === totalPages}
onclick={() => {
if (currentPage < totalPages) currentPage++;
}}
aria-label="Next page"
>
Next
</button>
</div>
{/if}
{/if}
</div>
{/if}
</div>
{/if}
</div>
</main>
<style>
.rss-page {
max-width: var(--content-width);
margin: 0 auto;
padding: 0 1rem;
}
.rss-setup {
padding: 2rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-setup {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.rss-info {
padding: 2rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-info {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.rss-feed-list {
list-style: none;
padding: 0;
margin: 0;
}
.rss-feed-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .rss-feed-item {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.rss-feed-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
word-break: break-all;
font-size: 0.875rem;
}
:global(.dark) .rss-feed-link {
color: var(--fog-dark-accent, #94a3b8);
}
.rss-feed-link:hover {
text-decoration: underline;
}
.feed-error {
display: block;
margin-top: 0.5rem;
color: var(--fog-danger, #dc2626);
font-size: 0.75rem;
}
:global(.dark) .feed-error {
color: var(--fog-dark-danger, #ef4444);
}
.rss-content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.rss-items-section {
padding: 2rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-items-section {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.rss-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.rss-item {
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
}
:global(.dark) .rss-item {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.rss-item-header {
margin-bottom: 0.5rem;
}
.rss-item-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.rss-feed-badge {
padding: 0.25rem 0.5rem;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #f1f5f9);
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
:global(.dark) .rss-feed-badge {
background: var(--fog-dark-accent, #94a3b8);
}
.rss-item-time {
font-size: 0.75rem;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .rss-item-time {
color: var(--fog-dark-text-light, #a8b8d0);
}
.rss-item-title {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
font-weight: 600;
}
.rss-item-link {
color: var(--fog-text, #475569);
text-decoration: none;
}
:global(.dark) .rss-item-link {
color: var(--fog-dark-text, #cbd5e1);
}
.rss-item-link:hover {
color: var(--fog-accent, #64748b);
text-decoration: underline;
}
:global(.dark) .rss-item-link:hover {
color: var(--fog-dark-accent, #94a3b8);
}
.rss-item-description {
color: var(--fog-text, #475569);
font-size: 0.875rem;
line-height: 1.6;
}
:global(.dark) .rss-item-description {
color: var(--fog-dark-text, #cbd5e1);
}
.rss-items-info {
padding: 0.5rem 0;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
margin-bottom: 2rem;
}
.pagination-top {
margin-top: 0;
margin-bottom: 1rem;
}
.pagination-bottom {
margin-top: 2rem;
}
.pagination-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .pagination-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .pagination-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.pagination-info {
min-width: 120px;
text-align: center;
}
</style>