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
+11
View File
@@ -20,6 +20,7 @@ import {
MAX_KEYWORDS, MAX_KEYWORDS,
MAX_KEYWORD_LENGTH, MAX_KEYWORD_LENGTH,
} from '@/lib/bots/contentSource'; } from '@/lib/bots/contentSource';
import { validateSourceReachability } from '@/lib/bots/contentSourceValidation';
type RouteContext = { params: Promise<{ id: string }> }; type RouteContext = { params: Promise<{ id: string }> };
@@ -115,6 +116,16 @@ export async function POST(request: Request, context: RouteContext) {
return NextResponse.json({ error: 'Not authorized' }, { status: 403 }); return NextResponse.json({ error: 'Not authorized' }, { status: 403 });
} }
await validateSourceReachability({
type: data.type,
url: data.url,
subreddit: data.subreddit,
apiKey: data.apiKey,
keywords: data.keywords,
braveNewsConfig: data.braveNewsConfig,
newsApiConfig: data.newsApiConfig,
});
// Add the content source // Add the content source
const source = await addSource(botId, { const source = await addSource(botId, {
type: data.type, type: data.type,
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAuth } from '@/lib/auth';
import { MAX_KEYWORDS, MAX_KEYWORD_LENGTH, SUPPORTED_SOURCE_TYPES, ContentSourceValidationError } from '@/lib/bots/contentSource';
import { validateSourceReachability } from '@/lib/bots/contentSourceValidation';
const braveNewsConfigSchema = z.object({
query: z.string().min(1, 'Search query is required'),
freshness: z.enum(['pd', 'pw', 'pm', 'py']).optional(),
country: z.string().length(2, 'Country must be a 2-letter ISO code').optional(),
searchLang: z.string().optional(),
count: z.number().min(1).max(50).optional(),
}).optional();
const newsApiConfigSchema = z.object({
provider: z.enum(['newsapi', 'gnews', 'newsdata']),
query: z.string().min(1, 'Search query is required'),
category: z.string().optional(),
country: z.string().optional(),
language: z.string().optional(),
}).optional();
const validateSourceSchema = z.object({
type: z.enum(['rss', 'reddit', 'news_api', 'brave_news', 'youtube'], {
message: `Source type must be one of: ${SUPPORTED_SOURCE_TYPES.join(', ')}`,
}),
url: z.string().url('URL must be a valid HTTP or HTTPS URL').max(2048, 'URL is too long'),
subreddit: z.string()
.regex(/^[a-zA-Z0-9_]{3,21}$/, 'Subreddit name must be 3-21 characters, alphanumeric and underscores only')
.optional(),
apiKey: z.string().min(10, 'API key is too short').max(256, 'API key is too long').optional(),
keywords: z.array(
z.string()
.min(1, 'Keyword cannot be empty')
.max(MAX_KEYWORD_LENGTH, `Keyword is too long (maximum ${MAX_KEYWORD_LENGTH} characters)`)
)
.max(MAX_KEYWORDS, `Maximum ${MAX_KEYWORDS} keywords allowed`)
.optional(),
braveNewsConfig: braveNewsConfigSchema,
newsApiConfig: newsApiConfigSchema,
}).refine(
(data) => data.type !== 'reddit' || !!data.subreddit,
{ message: 'Subreddit name is required for Reddit sources', path: ['subreddit'] }
).refine(
(data) => !['news_api', 'brave_news'].includes(data.type) || !!data.apiKey,
{ message: 'API key is required for news API sources', path: ['apiKey'] }
).refine(
(data) => data.type !== 'brave_news' || !!data.braveNewsConfig?.query,
{ message: 'Search query is required for Brave News sources', path: ['braveNewsConfig'] }
);
export async function POST(request: Request) {
try {
await requireAuth();
const body = await request.json();
const data = validateSourceSchema.parse(body);
await validateSourceReachability({
type: data.type,
url: data.url,
subreddit: data.subreddit,
apiKey: data.apiKey,
keywords: data.keywords,
braveNewsConfig: data.braveNewsConfig,
newsApiConfig: data.newsApiConfig,
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.issues },
{ status: 400 }
);
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof ContentSourceValidationError) {
return NextResponse.json(
{ error: error.message, code: error.code, details: error.errors },
{ status: 400 }
);
}
console.error('Validate content source error:', error);
return NextResponse.json(
{ error: 'Failed to validate content source' },
{ status: 500 }
);
}
}
+7 -54
View File
@@ -103,59 +103,7 @@ export async function GET(
})) || [], })) || [],
})); }));
const localSwarmReplyId = `swarm:${originDomain}:${originalPostId}`; mainPost.repliesCount = replyPosts.length;
const localReplies = await db.query.posts.findMany({
where: and(
eq(posts.swarmReplyToId, localSwarmReplyId),
eq(posts.isRemoved, false)
),
with: {
author: true,
media: true,
},
orderBy: [desc(posts.createdAt)],
});
if (localReplies.length > 0) {
let likedReplyIds = new Set<string>();
let repostedReplyIds = new Set<string | null>();
try {
const { requireAuth } = await import('@/lib/auth');
const { likes } = await import('@/db');
const viewer = await requireAuth();
const localReplyIds = localReplies.map(reply => reply.id);
const viewerLikes = await db.query.likes.findMany({
where: and(
eq(likes.userId, viewer.id),
inArray(likes.postId, localReplyIds)
),
});
likedReplyIds = new Set(viewerLikes.map(like => like.postId));
const viewerReposts = await db.query.posts.findMany({
where: and(
eq(posts.userId, viewer.id),
inArray(posts.repostOfId, localReplyIds),
eq(posts.isRemoved, false)
),
});
repostedReplyIds = new Set(viewerReposts.map(reply => reply.repostOfId));
} catch {
}
const formattedLocalReplies = localReplies.map((reply: any) => ({
...reply,
isLiked: likedReplyIds.has(reply.id),
isReposted: repostedReplyIds.has(reply.id),
}));
replyPosts = [...formattedLocalReplies, ...replyPosts]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
mainPost.repliesCount = (mainPost.repliesCount || 0) + localReplies.length;
}
// Check if current user has liked this post // Check if current user has liked this post
try { try {
@@ -214,6 +162,11 @@ export async function GET(
orderBy: [desc(posts.createdAt)], orderBy: [desc(posts.createdAt)],
}); });
mainPost = {
...post,
repliesCount: replies.length,
};
let allPostIds = [post.id, ...replies.map(r => r.id)]; let allPostIds = [post.id, ...replies.map(r => r.id)];
try { try {
@@ -243,7 +196,7 @@ export async function GET(
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId)); const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
mainPost = { mainPost = {
...post, ...mainPost,
isLiked: likedPostIds.has(post.id), isLiked: likedPostIds.has(post.id),
isReposted: repostedPostIds.has(post.id), isReposted: repostedPostIds.has(post.id),
} as any; } as any;
+82 -86
View File
@@ -86,16 +86,92 @@ export async function POST(request: Request) {
linkPreviewImage: data.linkPreview?.image, linkPreviewImage: data.linkPreview?.image,
}).returning(); }).returning();
let attachedMedia: typeof media.$inferSelect[] = []; let unattachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) { if (data.mediaIds?.length) {
await db.update(media) unattachedMedia = await db.query.media.findMany({
.set({ postId: post.id }) where: and(
.where(and(
inArray(media.id, data.mediaIds), inArray(media.id, data.mediaIds),
eq(media.userId, user.id), eq(media.userId, user.id),
isNull(media.postId), isNull(media.postId),
)); ),
});
}
try {
if (data.swarmReplyTo) {
const protocol = data.swarmReplyTo.nodeDomain.includes('localhost') ? 'http' : 'https';
const targetUrl = `${protocol}://${data.swarmReplyTo.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo.postId,
reply: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
did: user.did,
publicKey: user.publicKey,
},
nodeDomain,
mediaUrls: unattachedMedia.map(m => m.url),
},
};
const { createSignedPayload } = await import('@/lib/swarm/signature');
const { payload, signature } = await createSignedPayload(replyPayload);
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': nodeDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(body || `Remote node rejected reply (${response.status})`);
}
}
if (data.mediaIds?.length) {
await db.update(media)
.set({ postId: post.id })
.where(and(
inArray(media.id, data.mediaIds),
eq(media.userId, user.id),
isNull(media.postId),
));
}
// Update user's post count (atomic increment to prevent race conditions)
await db.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count (atomic increment)
if (data.replyToId) {
await db.update(posts)
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
.where(eq(posts.id, data.replyToId));
}
} catch (err) {
await db.delete(posts).where(eq(posts.id, post.id));
console.error('[Swarm] Error creating synchronized reply:', err);
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to deliver reply to origin node' },
{ status: 502 }
);
}
let attachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) {
attachedMedia = await db.query.media.findMany({ attachedMedia = await db.query.media.findMany({
where: and( where: and(
inArray(media.id, data.mediaIds), inArray(media.id, data.mediaIds),
@@ -105,66 +181,6 @@ export async function POST(request: Request) {
}); });
} }
// Update user's post count (atomic increment to prevent race conditions)
await db.update(users)
.set({ postsCount: sql`${users.postsCount} + 1` })
.where(eq(users.id, user.id));
// If this is a reply, update the parent's reply count (atomic increment)
if (data.replyToId) {
await db.update(posts)
.set({ repliesCount: sql`${posts.repliesCount} + 1` })
.where(eq(posts.id, data.replyToId));
}
if (data.swarmReplyTo) {
(async () => {
try {
const protocol = data.swarmReplyTo!.nodeDomain.includes('localhost') ? 'http' : 'https';
const targetUrl = `${protocol}://${data.swarmReplyTo!.nodeDomain}/api/swarm/replies`;
const replyPayload = {
postId: data.swarmReplyTo!.postId,
reply: {
id: post.id,
content: post.content,
createdAt: post.createdAt.toISOString(),
author: {
handle: user.handle,
displayName: user.displayName || user.handle,
avatarUrl: user.avatarUrl || undefined,
did: user.did,
publicKey: user.publicKey,
},
nodeDomain,
mediaUrls: attachedMedia.map(m => m.url),
},
};
const { createSignedPayload } = await import('@/lib/swarm/signature');
const { payload, signature } = await createSignedPayload(replyPayload);
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Swarm-Source-Domain': nodeDomain,
'X-Swarm-Signature': signature,
},
body: JSON.stringify(payload),
});
if (response.ok) {
console.log(`[Swarm] Reply delivered to ${data.swarmReplyTo!.nodeDomain}`);
} else {
console.error(`[Swarm] Failed to deliver reply: ${response.status}`);
}
} catch (err) {
console.error('[Swarm] Error delivering reply:', err);
}
})();
}
// Handle local mentions (create notifications for users on this node) // Handle local mentions (create notifications for users on this node)
(async () => { (async () => {
try { try {
@@ -538,26 +554,6 @@ export async function GET(request: Request) {
includeNsfw, includeNsfw,
}); });
// Transform swarm posts to match local post format
const localSwarmReplyIds = swarmResult.posts.map(sp => `swarm:${sp.nodeDomain}:${sp.id}`);
const localReplyCounts = localSwarmReplyIds.length > 0
? await db.select({
swarmReplyToId: posts.swarmReplyToId,
count: sql<number>`count(*)`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, localSwarmReplyIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId)
: [];
const localReplyCountMap = new Map(
localReplyCounts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, Number(row.count || 0)])
);
const swarmPosts = swarmResult.posts.map(sp => ({ const swarmPosts = swarmResult.posts.map(sp => ({
id: `swarm:${sp.nodeDomain}:${sp.id}`, id: `swarm:${sp.nodeDomain}:${sp.id}`,
originalPostId: sp.id, // Keep the original ID for replies originalPostId: sp.id, // Keep the original ID for replies
@@ -565,7 +561,7 @@ export async function GET(request: Request) {
createdAt: new Date(sp.createdAt), createdAt: new Date(sp.createdAt),
likesCount: sp.likeCount, likesCount: sp.likeCount,
repostsCount: sp.repostCount, repostsCount: sp.repostCount,
repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0), repliesCount: sp.replyCount,
isSwarm: true, isSwarm: true,
nodeDomain: sp.nodeDomain, nodeDomain: sp.nodeDomain,
author: { author: {
-22
View File
@@ -5,10 +5,8 @@
*/ */
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, posts } from '@/db';
import { fetchSwarmTimeline } from '@/lib/swarm/timeline'; import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
import { getSession } from '@/lib/auth'; import { getSession } from '@/lib/auth';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes'; import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
/** /**
@@ -20,7 +18,6 @@ import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const refresh = searchParams.get('refresh') === 'true';
const cursor = searchParams.get('cursor') || undefined; const cursor = searchParams.get('cursor') || undefined;
// Check user's NSFW preference // Check user's NSFW preference
@@ -34,24 +31,6 @@ export async function GET(request: NextRequest) {
// Fetch swarm timeline (no caching - user preferences vary) // Fetch swarm timeline (no caching - user preferences vary)
const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor }); const timeline = await fetchSwarmTimeline(10, 15, { includeNsfw, cursor });
const swarmReplyIds = timeline.posts.map(post => `swarm:${post.nodeDomain}:${post.id}`);
const localReplyCounts = db && swarmReplyIds.length > 0
? await db.select({
swarmReplyToId: posts.swarmReplyToId,
count: sql<number>`count(*)::int`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, swarmReplyIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId)
: [];
const localReplyCountMap = new Map(
localReplyCounts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, row.count])
);
const session = await getSession().catch(() => null); const session = await getSession().catch(() => null);
const viewer = session?.user; const viewer = session?.user;
@@ -71,7 +50,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
posts: timeline.posts.map(post => ({ posts: timeline.posts.map(post => ({
...post, ...post,
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`), isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
})), })),
sources: timeline.sources, sources: timeline.sources,
+1 -1
View File
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
createdAt: post.createdAt.toISOString(), createdAt: post.createdAt.toISOString(),
likesCount: post.likesCount, likesCount: post.likesCount,
repostsCount: post.repostsCount, repostsCount: post.repostsCount,
repliesCount: post.repliesCount, repliesCount: replies.length,
author: { author: {
handle: author.handle, handle: author.handle,
displayName: author.displayName, displayName: author.displayName,
+21 -15
View File
@@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, notifications } from '@/db'; import { db, posts, users, media, notifications } from '@/db';
import { eq, desc, and } from 'drizzle-orm'; import { eq, desc, and, sql } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { verifySwarmRequest } from '@/lib/swarm/signature'; import { verifySwarmRequest } from '@/lib/swarm/signature';
import { upsertRemoteUser } from '@/lib/swarm/user-cache'; import { upsertRemoteUser } from '@/lib/swarm/user-cache';
@@ -31,6 +31,20 @@ const swarmReplySchema = z.object({
}), }),
}); });
async function syncParentReplyCount(postId: string) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(posts)
.where(and(
eq(posts.replyToId, postId),
eq(posts.isRemoved, false)
));
await db.update(posts)
.set({ repliesCount: Number(count || 0) })
.where(eq(posts.id, postId));
}
/** /**
* POST /api/swarm/replies * POST /api/swarm/replies
* *
@@ -129,9 +143,7 @@ export async function POST(request: NextRequest) {
); );
} }
await db.update(posts) await syncParentReplyCount(data.postId);
.set({ repliesCount: parentPost.repliesCount + 1 })
.where(eq(posts.id, data.postId));
if (parentPost.userId !== remoteUser.id) { if (parentPost.userId !== remoteUser.id) {
await db.insert(notifications).values({ await db.insert(notifications).values({
@@ -183,21 +195,15 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ success: true, message: 'Reply not found or already deleted' }); return NextResponse.json({ success: true, message: 'Reply not found or already deleted' });
} }
// Decrement parent's reply count const parentReplyToId = existingReply.replyToId;
if (existingReply.replyToId) {
const parentPost = await db.query.posts.findFirst({
where: eq(posts.id, existingReply.replyToId),
});
if (parentPost && parentPost.repliesCount > 0) {
await db.update(posts)
.set({ repliesCount: parentPost.repliesCount - 1 })
.where(eq(posts.id, existingReply.replyToId));
}
}
// Delete the reply // Delete the reply
await db.delete(posts).where(eq(posts.id, existingReply.id)); await db.delete(posts).where(eq(posts.id, existingReply.id));
if (parentReplyToId) {
await syncParentReplyCount(parentReplyToId);
}
console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`); console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
+8
View File
@@ -12,6 +12,9 @@ export interface SwarmPost {
id: string; id: string;
content: string; content: string;
createdAt: string; createdAt: string;
isReply?: boolean;
replyToId?: string | null;
swarmReplyToId?: string | null;
author: { author: {
handle: string; handle: string;
displayName: string; displayName: string;
@@ -84,6 +87,8 @@ export async function GET(request: NextRequest) {
id: posts.id, id: posts.id,
content: posts.content, content: posts.content,
createdAt: posts.createdAt, createdAt: posts.createdAt,
replyToId: posts.replyToId,
swarmReplyToId: posts.swarmReplyToId,
isNsfw: posts.isNsfw, isNsfw: posts.isNsfw,
likesCount: posts.likesCount, likesCount: posts.likesCount,
repostsCount: posts.repostsCount, repostsCount: posts.repostsCount,
@@ -120,6 +125,9 @@ export async function GET(request: NextRequest) {
id: post.id, id: post.id,
content: post.content, content: post.content,
createdAt: post.createdAt.toISOString(), createdAt: post.createdAt.toISOString(),
isReply: Boolean(post.replyToId || post.swarmReplyToId),
replyToId: post.replyToId,
swarmReplyToId: post.swarmReplyToId,
author: { author: {
handle: post.authorHandle, handle: post.authorHandle,
displayName: post.authorDisplayName || post.authorHandle, displayName: post.authorDisplayName || post.authorHandle,
+1 -23
View File
@@ -146,28 +146,6 @@ export async function GET(request: Request, context: RouteContext) {
avatarUrl: profile.avatarUrl, avatarUrl: profile.avatarUrl,
}; };
const swarmPostIds = profileData.posts.map((post: any) => `swarm:${remote.domain}:${post.id}`);
let localReplyCounts = new Map<string, number>();
if (swarmPostIds.length > 0) {
const counts = await db.select({
swarmReplyToId: posts.swarmReplyToId,
replyCount: sql<number>`count(*)::int`,
})
.from(posts)
.where(and(
inArray(posts.swarmReplyToId, swarmPostIds),
eq(posts.isRemoved, false)
))
.groupBy(posts.swarmReplyToId);
localReplyCounts = new Map(
counts
.filter(row => row.swarmReplyToId)
.map(row => [row.swarmReplyToId as string, row.replyCount])
);
}
const remotePosts = profileData.posts.map((post: any) => ({ const remotePosts = profileData.posts.map((post: any) => ({
id: post.id, id: post.id,
originalPostId: post.id, originalPostId: post.id,
@@ -175,7 +153,7 @@ export async function GET(request: Request, context: RouteContext) {
createdAt: post.createdAt, createdAt: post.createdAt,
likesCount: post.likesCount || 0, likesCount: post.likesCount || 0,
repostsCount: post.repostsCount || 0, repostsCount: post.repostsCount || 0,
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0), repliesCount: post.repliesCount || 0,
author, author,
media: post.media || [], media: post.media || [],
linkPreviewUrl: post.linkPreviewUrl || null, linkPreviewUrl: post.linkPreviewUrl || null,
+91 -29
View File
@@ -46,6 +46,8 @@ export default function EditBotPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sourceError, setSourceError] = useState('');
const [validatingSource, setValidatingSource] = useState(false);
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -80,6 +82,12 @@ export default function EditBotPage() {
fetchBot(); fetchBot();
}, [botId]); }, [botId]);
useEffect(() => {
if (sourceError) {
setSourceError('');
}
}, [newSource]);
const fetchBot = async () => { const fetchBot = async () => {
try { try {
const [botRes, sourcesRes] = await Promise.all([ const [botRes, sourcesRes] = await Promise.all([
@@ -147,46 +155,94 @@ export default function EditBotPage() {
} }
}; };
const handleAddSource = () => { const buildSourcePayload = (source: ContentSource) => {
// Validate based on type const payload: Record<string, unknown> = {
if (newSource.type === 'brave_news') { type: source.type,
if (!newSource.braveQuery || !newSource.apiKey) return; url: source.url,
// Build URL from config subreddit: source.subreddit,
apiKey: source.apiKey,
};
if (source.type === 'brave_news' && source.braveQuery) {
const url = new URL('https://api.search.brave.com/res/v1/news/search'); const url = new URL('https://api.search.brave.com/res/v1/news/search');
url.searchParams.set('q', newSource.braveQuery); url.searchParams.set('q', source.braveQuery);
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness); if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness);
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry); if (source.braveCountry) url.searchParams.set('country', source.braveCountry);
setSources([...sources, { ...newSource, url: url.toString() }]); payload.url = url.toString();
} else if (newSource.type === 'news_api') { payload.braveNewsConfig = {
if (!newSource.newsQuery || !newSource.apiKey) return; query: source.braveQuery,
// Build URL from config freshness: source.braveFreshness,
country: source.braveCountry || undefined,
};
} else if (source.type === 'news_api' && source.newsQuery) {
let baseUrl: string; let baseUrl: string;
const params = new URLSearchParams(); const params = new URLSearchParams();
switch (newSource.newsProvider) { switch (source.newsProvider) {
case 'gnews': case 'gnews':
baseUrl = 'https://gnews.io/api/v4/search'; baseUrl = 'https://gnews.io/api/v4/search';
params.set('q', newSource.newsQuery); params.set('q', source.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry); if (source.newsCountry) params.set('country', source.newsCountry);
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage); if (source.newsLanguage) params.set('lang', source.newsLanguage);
if (newSource.newsCategory) params.set('topic', newSource.newsCategory); if (source.newsCategory) params.set('topic', source.newsCategory);
break; break;
case 'newsdata': case 'newsdata':
baseUrl = 'https://newsdata.io/api/1/news'; baseUrl = 'https://newsdata.io/api/1/news';
params.set('q', newSource.newsQuery); params.set('q', source.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry); if (source.newsCountry) params.set('country', source.newsCountry);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); if (source.newsLanguage) params.set('language', source.newsLanguage);
if (newSource.newsCategory) params.set('category', newSource.newsCategory); if (source.newsCategory) params.set('category', source.newsCategory);
break; break;
default: default:
baseUrl = 'https://newsapi.org/v2/everything'; baseUrl = 'https://newsapi.org/v2/everything';
params.set('q', newSource.newsQuery); params.set('q', source.newsQuery);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); if (source.newsLanguage) params.set('language', source.newsLanguage);
} }
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]); payload.url = `${baseUrl}?${params.toString()}`;
} else { payload.newsApiConfig = {
if (!newSource.url) return; provider: source.newsProvider,
setSources([...sources, { ...newSource }]); query: source.newsQuery,
category: source.newsCategory || undefined,
country: source.newsCountry || undefined,
language: source.newsLanguage || undefined,
};
} }
return {
payload,
source: {
...source,
url: payload.url as string,
},
};
};
const handleAddSource = async () => {
const { payload, source } = buildSourcePayload(newSource);
setSourceError('');
setValidatingSource(true);
try {
const response = await fetch('/api/bots/validate-source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const details = Array.isArray(data.details) ? data.details.join(', ') : '';
throw new Error(details || data.error || 'Source validation failed');
}
setSources([...sources, source]);
} catch (err) {
setSourceError(err instanceof Error ? err.message : 'Source validation failed');
return;
} finally {
setValidatingSource(false);
}
setNewSource({ setNewSource({
type: 'rss', type: 'rss',
url: '', url: '',
@@ -695,11 +751,17 @@ export default function EditBotPage() {
</> </>
)} )}
{sourceError && (
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
{sourceError}
</div>
)}
<button <button
type="button" type="button"
onClick={handleAddSource} onClick={handleAddSource}
className="btn btn-primary" className="btn btn-primary"
disabled={ disabled={validatingSource ||
(newSource.type === 'rss' && !newSource.url) || (newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) || (newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) || (newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
@@ -707,7 +769,7 @@ export default function EditBotPage() {
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey)) (newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
} }
> >
Add Source {validatingSource ? 'Validating Source...' : 'Add Source'}
</button> </button>
</div> </div>
</div> </div>
+2 -1
View File
@@ -205,7 +205,8 @@ export default function BotDetailPage() {
fetchSources(); fetchSources();
} else { } else {
const data = await response.json(); const data = await response.json();
showToast(`Failed to add source: ${data.error || 'Unknown error'}`, 'error'); const details = Array.isArray(data.details) ? ` ${data.details.join(', ')}` : '';
showToast(`Failed to add source: ${data.error || 'Unknown error'}${details}`, 'error');
} }
} catch (error) { } catch (error) {
showToast('Failed to add source', 'error'); showToast('Failed to add source', 'error');
+91 -29
View File
@@ -39,6 +39,8 @@ export default function NewBotPage() {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [sourceError, setSourceError] = useState('');
const [validatingSource, setValidatingSource] = useState(false);
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -98,46 +100,100 @@ export default function NewBotPage() {
fetchPreviousBotSettings(); fetchPreviousBotSettings();
}, []); }, []);
const handleAddSource = () => { useEffect(() => {
// Validate based on type if (sourceError) {
if (newSource.type === 'brave_news') { setSourceError('');
if (!newSource.braveQuery || !newSource.apiKey) return; }
// Build URL from config }, [newSource]);
const buildSourcePayload = (source: ContentSource) => {
const payload: Record<string, unknown> = {
type: source.type,
url: source.url,
subreddit: source.subreddit,
apiKey: source.apiKey,
};
if (source.type === 'brave_news' && source.braveQuery) {
const url = new URL('https://api.search.brave.com/res/v1/news/search'); const url = new URL('https://api.search.brave.com/res/v1/news/search');
url.searchParams.set('q', newSource.braveQuery); url.searchParams.set('q', source.braveQuery);
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness); if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness);
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry); if (source.braveCountry) url.searchParams.set('country', source.braveCountry);
setSources([...sources, { ...newSource, url: url.toString() }]); payload.url = url.toString();
} else if (newSource.type === 'news_api') { payload.braveNewsConfig = {
if (!newSource.newsQuery || !newSource.apiKey) return; query: source.braveQuery,
// Build URL from config freshness: source.braveFreshness,
country: source.braveCountry || undefined,
};
} else if (source.type === 'news_api' && source.newsQuery) {
let baseUrl: string; let baseUrl: string;
const params = new URLSearchParams(); const params = new URLSearchParams();
switch (newSource.newsProvider) { switch (source.newsProvider) {
case 'gnews': case 'gnews':
baseUrl = 'https://gnews.io/api/v4/search'; baseUrl = 'https://gnews.io/api/v4/search';
params.set('q', newSource.newsQuery); params.set('q', source.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry); if (source.newsCountry) params.set('country', source.newsCountry);
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage); if (source.newsLanguage) params.set('lang', source.newsLanguage);
if (newSource.newsCategory) params.set('topic', newSource.newsCategory); if (source.newsCategory) params.set('topic', source.newsCategory);
break; break;
case 'newsdata': case 'newsdata':
baseUrl = 'https://newsdata.io/api/1/news'; baseUrl = 'https://newsdata.io/api/1/news';
params.set('q', newSource.newsQuery); params.set('q', source.newsQuery);
if (newSource.newsCountry) params.set('country', newSource.newsCountry); if (source.newsCountry) params.set('country', source.newsCountry);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); if (source.newsLanguage) params.set('language', source.newsLanguage);
if (newSource.newsCategory) params.set('category', newSource.newsCategory); if (source.newsCategory) params.set('category', source.newsCategory);
break; break;
default: default:
baseUrl = 'https://newsapi.org/v2/everything'; baseUrl = 'https://newsapi.org/v2/everything';
params.set('q', newSource.newsQuery); params.set('q', source.newsQuery);
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage); if (source.newsLanguage) params.set('language', source.newsLanguage);
} }
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]); payload.url = `${baseUrl}?${params.toString()}`;
} else { payload.newsApiConfig = {
if (!newSource.url) return; provider: source.newsProvider,
setSources([...sources, { ...newSource }]); query: source.newsQuery,
category: source.newsCategory || undefined,
country: source.newsCountry || undefined,
language: source.newsLanguage || undefined,
};
} }
return {
payload,
source: {
...source,
url: payload.url as string,
},
};
};
const handleAddSource = async () => {
const { payload, source } = buildSourcePayload(newSource);
setSourceError('');
setValidatingSource(true);
try {
const response = await fetch('/api/bots/validate-source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const details = Array.isArray(data.details) ? data.details.join(', ') : '';
throw new Error(details || data.error || 'Source validation failed');
}
setSources([...sources, source]);
} catch (err) {
setSourceError(err instanceof Error ? err.message : 'Source validation failed');
return;
} finally {
setValidatingSource(false);
}
setNewSource({ setNewSource({
type: 'rss', type: 'rss',
url: '', url: '',
@@ -648,11 +704,17 @@ export default function NewBotPage() {
</> </>
)} )}
{sourceError && (
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
{sourceError}
</div>
)}
<button <button
type="button" type="button"
onClick={handleAddSource} onClick={handleAddSource}
className="btn btn-primary" className="btn btn-primary"
disabled={ disabled={validatingSource ||
(newSource.type === 'rss' && !newSource.url) || (newSource.type === 'rss' && !newSource.url) ||
(newSource.type === 'reddit' && !newSource.subreddit) || (newSource.type === 'reddit' && !newSource.subreddit) ||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) || (newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
@@ -660,7 +722,7 @@ export default function NewBotPage() {
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey)) (newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
} }
> >
Add Source {validatingSource ? 'Validating Source...' : 'Add Source'}
</button> </button>
</div> </div>
</div> </div>
+16 -5
View File
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react'; import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVertical } from 'lucide-react';
import { useFormattedHandle } from '@/lib/utils/handle'; import Link from 'next/link';
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
interface Conversation { interface Conversation {
@@ -392,10 +393,20 @@ export default function ChatPage() {
)} )}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div> <Link
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> href={getProfilePath(selectedConversation.participant2.handle)}
{selectedHandle} style={{
</div> display: 'block',
color: 'var(--foreground)',
textDecoration: 'none',
minWidth: 0,
}}
>
<div style={{ fontWeight: 600, fontSize: '15px' }}>{selectedConversation.participant2.displayName}</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{selectedHandle}
</div>
</Link>
</div> </div>
<button <button
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }} onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { getProfilePath } from '@/lib/utils/handle';
type AdminUser = { type AdminUser = {
id: string; id: string;
@@ -440,7 +441,7 @@ export default function ModerationPage() {
)} )}
</div> </div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Link href={`/@${user.handle}`} className="btn btn-ghost btn-sm"> <Link href={getProfilePath(user.handle)} className="btn btn-ghost btn-sm">
View View
</Link> </Link>
{user.isSuspended ? ( {user.isSuspended ? (
+4 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { BellIcon } from '@/components/Icons'; import { BellIcon } from '@/components/Icons';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { getProfilePath } from '@/lib/utils/handle';
interface NotificationActor { interface NotificationActor {
id: string; id: string;
@@ -183,6 +184,7 @@ function NotificationItem({
}) { }) {
const isUnread = !notification.readAt; const isUnread = !notification.readAt;
const actor = notification.actor; const actor = notification.actor;
const actorProfilePath = actor ? getProfilePath(actor.handle) : '#';
return ( return (
<div <div
@@ -195,7 +197,7 @@ function NotificationItem({
alignItems: 'flex-start', alignItems: 'flex-start',
}} }}
> >
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}> <Link href={actorProfilePath} style={{ flexShrink: 0 }}>
{actor?.avatarUrl ? ( {actor?.avatarUrl ? (
<img <img
src={actor.avatarUrl} src={actor.avatarUrl}
@@ -227,7 +229,7 @@ function NotificationItem({
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}>
<Link <Link
href={actor ? `/@${actor.handle}` : '#'} href={actorProfilePath}
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }} style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
> >
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span> {actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
+2 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { useFormattedHandle } from '@/lib/utils/handle'; import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
import { PostCard } from '@/components/PostCard'; import { PostCard } from '@/components/PostCard';
import { Post } from '@/lib/types'; import { Post } from '@/lib/types';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
@@ -70,7 +70,7 @@ function UserCard({ user }: { user: User }) {
const fullHandle = useFormattedHandle(user.handle); const fullHandle = useFormattedHandle(user.handle);
return ( return (
<Link <Link
href={`/@${user.handle}`} href={getProfilePath(user.handle)}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
+2 -1
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getProfilePath } from '@/lib/utils/handle';
import { ArrowLeftIcon } from '@/components/Icons'; import { ArrowLeftIcon } from '@/components/Icons';
import { UserX, Globe, Trash2 } from 'lucide-react'; import { UserX, Globe, Trash2 } from 'lucide-react';
@@ -179,7 +180,7 @@ export default function ModerationSettingsPage() {
}} }}
> >
<Link <Link
href={`/@${user.handle}`} href={getProfilePath(user.handle)}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
+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 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 * Extract the first URL from post content
*/ */
@@ -183,16 +193,19 @@ export async function fetchSwarmTimeline(
const sources: TimelineResult['sources'] = []; const sources: TimelineResult['sources'] = [];
for (const result of results) { for (const result of results) {
const nonReplyPosts = result.posts.filter(post => !isReplyPost(post));
// Filter NSFW posts only if user doesn't want NSFW content // 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 // A post is NSFW if it's explicitly marked OR comes from an NSFW node
const filteredPosts = includeNsfw const filteredPosts = includeNsfw
? result.posts ? nonReplyPosts
: result.posts.filter(p => !p.isNsfw && !p.nodeIsNsfw); : nonReplyPosts.filter(p => !p.isNsfw && !p.nodeIsNsfw);
// Log filtering details for debugging // Log filtering details for debugging
const nsfwPosts = result.posts.filter(p => p.isNsfw); const nsfwPosts = nonReplyPosts.filter(p => p.isNsfw);
const nodeNsfwPosts = result.posts.filter(p => p.nodeIsNsfw); const nodeNsfwPosts = nonReplyPosts.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 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({ sources.push({
domain: result.domain, domain: result.domain,
+9
View File
@@ -27,6 +27,15 @@ export function formatFullHandle(handle: string, nodeDomain?: string | null): st
return `@${cleanHandle}@${domain}`; 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. * React hook that formats a handle using the runtime domain config.
* Use this in client components instead of formatFullHandle for local handles. * Use this in client components instead of formatFullHandle for local handles.