Fix reply consistency and source validation

This commit is contained in:
cyph3rasi
2026-03-08 18:29:42 -07:00
parent a8f104ee14
commit 5f21ea8e5d
20 changed files with 545 additions and 276 deletions
+83
View File
@@ -0,0 +1,83 @@
import type { ContentSourceConfig } from './contentSource';
import { ContentSourceValidationError, validateContentSourceConfig } from './contentSource';
import { fetchRSSFeed, fetchRedditPosts, fetchNewsApi, fetchBraveNews, NetworkError, ParseError } from './contentFetcher';
import type { FeedItem } from './rssParser';
const VALIDATION_TIMEOUT_MS = 10000;
export async function validateSourceReachability(config: ContentSourceConfig): Promise<void> {
const validation = validateContentSourceConfig(config);
if (!validation.valid) {
throw new ContentSourceValidationError(
`Invalid content source configuration: ${validation.errors.join(', ')}`,
validation.errors
);
}
try {
let items: FeedItem[] = [];
switch (config.type) {
case 'rss':
case 'youtube':
items = await fetchRSSFeed(config.url, { maxItems: 1, timeout: VALIDATION_TIMEOUT_MS });
break;
case 'reddit':
items = await fetchRedditPosts(
config.subreddit || '',
{ maxItems: 1, timeout: VALIDATION_TIMEOUT_MS }
);
break;
case 'news_api':
items = await fetchNewsApi(
config.url,
config.apiKey || '',
{ maxItems: 1, timeout: VALIDATION_TIMEOUT_MS }
);
break;
case 'brave_news':
items = await fetchBraveNews(
config.braveNewsConfig || { query: '' },
config.apiKey || '',
{ maxItems: 1, timeout: VALIDATION_TIMEOUT_MS }
);
break;
default:
items = [];
}
if (!items || items.length === 0) {
throw new ContentSourceValidationError(
'Source is reachable but returned no items',
['Source is reachable but returned no items']
);
}
} catch (error) {
if (error instanceof ContentSourceValidationError) {
throw error;
}
if (error instanceof NetworkError || error instanceof ParseError) {
throw new ContentSourceValidationError(
`Could not validate source: ${error.message}`,
[error.message]
);
}
if (error instanceof Error) {
throw new ContentSourceValidationError(
`Could not validate source: ${error.message}`,
[error.message]
);
}
throw new ContentSourceValidationError(
'Could not validate source',
['Could not validate source']
);
}
}
+18 -5
View File
@@ -18,6 +18,16 @@ interface TimelineOptions {
cursor?: string; // Timestamp cursor for pagination
}
function isReplyPost(post: SwarmPost): boolean {
return Boolean(
post.isReply ||
post.replyToId ||
post.swarmReplyToId ||
// Defensive against older or non-conforming node payloads.
(post as SwarmPost & { replyTo?: unknown }).replyTo
);
}
/**
* Extract the first URL from post content
*/
@@ -183,16 +193,19 @@ export async function fetchSwarmTimeline(
const sources: TimelineResult['sources'] = [];
for (const result of results) {
const nonReplyPosts = result.posts.filter(post => !isReplyPost(post));
// Filter NSFW posts only if user doesn't want NSFW content
// A post is NSFW if it's explicitly marked OR comes from an NSFW node
const filteredPosts = includeNsfw
? result.posts
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
? nonReplyPosts
: nonReplyPosts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
// Log filtering details for debugging
const nsfwPosts = result.posts.filter(p => p.isNsfw);
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw);
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts fetched, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter (includeNsfw: ${includeNsfw})`);
const nsfwPosts = nonReplyPosts.filter(p => p.isNsfw);
const nodeNsfwPosts = nonReplyPosts.filter(p => p.nodeIsNsfw);
const replyPosts = result.posts.length - nonReplyPosts.length;
console.log(`[Swarm Timeline] ${result.domain}: ${result.posts.length} posts fetched, ${replyPosts} replies filtered, ${nsfwPosts.length} marked NSFW, ${nodeNsfwPosts.length} from NSFW node, ${filteredPosts.length} after filter (includeNsfw: ${includeNsfw})`);
sources.push({
domain: result.domain,
+9
View File
@@ -27,6 +27,15 @@ export function formatFullHandle(handle: string, nodeDomain?: string | null): st
return `@${cleanHandle}@${domain}`;
}
export function getProfilePath(handle: string): string {
if (!handle) {
return '/u';
}
const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle;
return `/u/${cleanHandle}`;
}
/**
* React hook that formats a handle using the runtime domain config.
* Use this in client components instead of formatFullHandle for local handles.