Fix reply consistency and source validation
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
MAX_KEYWORDS,
|
||||
MAX_KEYWORD_LENGTH,
|
||||
} from '@/lib/bots/contentSource';
|
||||
import { validateSourceReachability } from '@/lib/bots/contentSourceValidation';
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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
|
||||
const source = await addSource(botId, {
|
||||
type: data.type,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,59 +103,7 @@ export async function GET(
|
||||
})) || [],
|
||||
}));
|
||||
|
||||
const localSwarmReplyId = `swarm:${originDomain}:${originalPostId}`;
|
||||
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;
|
||||
}
|
||||
mainPost.repliesCount = replyPosts.length;
|
||||
|
||||
// Check if current user has liked this post
|
||||
try {
|
||||
@@ -214,6 +162,11 @@ export async function GET(
|
||||
orderBy: [desc(posts.createdAt)],
|
||||
});
|
||||
|
||||
mainPost = {
|
||||
...post,
|
||||
repliesCount: replies.length,
|
||||
};
|
||||
|
||||
let allPostIds = [post.id, ...replies.map(r => r.id)];
|
||||
|
||||
try {
|
||||
@@ -243,7 +196,7 @@ export async function GET(
|
||||
const repostedPostIds = new Set(viewerReposts.map(r => r.repostOfId));
|
||||
|
||||
mainPost = {
|
||||
...post,
|
||||
...mainPost,
|
||||
isLiked: likedPostIds.has(post.id),
|
||||
isReposted: repostedPostIds.has(post.id),
|
||||
} as any;
|
||||
|
||||
+82
-86
@@ -86,16 +86,92 @@ export async function POST(request: Request) {
|
||||
linkPreviewImage: data.linkPreview?.image,
|
||||
}).returning();
|
||||
|
||||
let attachedMedia: typeof media.$inferSelect[] = [];
|
||||
let unattachedMedia: typeof media.$inferSelect[] = [];
|
||||
if (data.mediaIds?.length) {
|
||||
await db.update(media)
|
||||
.set({ postId: post.id })
|
||||
.where(and(
|
||||
unattachedMedia = await db.query.media.findMany({
|
||||
where: and(
|
||||
inArray(media.id, data.mediaIds),
|
||||
eq(media.userId, user.id),
|
||||
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({
|
||||
where: and(
|
||||
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)
|
||||
(async () => {
|
||||
try {
|
||||
@@ -538,26 +554,6 @@ export async function GET(request: Request) {
|
||||
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 => ({
|
||||
id: `swarm:${sp.nodeDomain}:${sp.id}`,
|
||||
originalPostId: sp.id, // Keep the original ID for replies
|
||||
@@ -565,7 +561,7 @@ export async function GET(request: Request) {
|
||||
createdAt: new Date(sp.createdAt),
|
||||
likesCount: sp.likeCount,
|
||||
repostsCount: sp.repostCount,
|
||||
repliesCount: sp.replyCount + (localReplyCountMap.get(`swarm:${sp.nodeDomain}:${sp.id}`) || 0),
|
||||
repliesCount: sp.replyCount,
|
||||
isSwarm: true,
|
||||
nodeDomain: sp.nodeDomain,
|
||||
author: {
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, posts } from '@/db';
|
||||
import { fetchSwarmTimeline } from '@/lib/swarm/timeline';
|
||||
import { getSession } from '@/lib/auth';
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||
|
||||
/**
|
||||
@@ -20,7 +18,6 @@ import { getViewerSwarmLikedPostIds } from '@/lib/swarm/likes';
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const refresh = searchParams.get('refresh') === 'true';
|
||||
const cursor = searchParams.get('cursor') || undefined;
|
||||
|
||||
// Check user's NSFW preference
|
||||
@@ -34,24 +31,6 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Fetch swarm timeline (no caching - user preferences vary)
|
||||
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 viewer = session?.user;
|
||||
@@ -71,7 +50,6 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({
|
||||
posts: timeline.posts.map(post => ({
|
||||
...post,
|
||||
replyCount: post.replyCount + (localReplyCountMap.get(`swarm:${post.nodeDomain}:${post.id}`) || 0),
|
||||
isLiked: likedPostIds.has(`swarm:${post.nodeDomain}:${post.id}`),
|
||||
})),
|
||||
sources: timeline.sources,
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
likesCount: post.likesCount,
|
||||
repostsCount: post.repostsCount,
|
||||
repliesCount: post.repliesCount,
|
||||
repliesCount: replies.length,
|
||||
author: {
|
||||
handle: author.handle,
|
||||
displayName: author.displayName,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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 { verifySwarmRequest } from '@/lib/swarm/signature';
|
||||
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
|
||||
*
|
||||
@@ -129,9 +143,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
await db.update(posts)
|
||||
.set({ repliesCount: parentPost.repliesCount + 1 })
|
||||
.where(eq(posts.id, data.postId));
|
||||
await syncParentReplyCount(data.postId);
|
||||
|
||||
if (parentPost.userId !== remoteUser.id) {
|
||||
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' });
|
||||
}
|
||||
|
||||
// Decrement parent's reply count
|
||||
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));
|
||||
}
|
||||
}
|
||||
const parentReplyToId = existingReply.replyToId;
|
||||
|
||||
// Delete the reply
|
||||
await db.delete(posts).where(eq(posts.id, existingReply.id));
|
||||
|
||||
if (parentReplyToId) {
|
||||
await syncParentReplyCount(parentReplyToId);
|
||||
}
|
||||
|
||||
console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface SwarmPost {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
isReply?: boolean;
|
||||
replyToId?: string | null;
|
||||
swarmReplyToId?: string | null;
|
||||
author: {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
@@ -84,6 +87,8 @@ export async function GET(request: NextRequest) {
|
||||
id: posts.id,
|
||||
content: posts.content,
|
||||
createdAt: posts.createdAt,
|
||||
replyToId: posts.replyToId,
|
||||
swarmReplyToId: posts.swarmReplyToId,
|
||||
isNsfw: posts.isNsfw,
|
||||
likesCount: posts.likesCount,
|
||||
repostsCount: posts.repostsCount,
|
||||
@@ -120,6 +125,9 @@ export async function GET(request: NextRequest) {
|
||||
id: post.id,
|
||||
content: post.content,
|
||||
createdAt: post.createdAt.toISOString(),
|
||||
isReply: Boolean(post.replyToId || post.swarmReplyToId),
|
||||
replyToId: post.replyToId,
|
||||
swarmReplyToId: post.swarmReplyToId,
|
||||
author: {
|
||||
handle: post.authorHandle,
|
||||
displayName: post.authorDisplayName || post.authorHandle,
|
||||
|
||||
@@ -146,28 +146,6 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
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) => ({
|
||||
id: post.id,
|
||||
originalPostId: post.id,
|
||||
@@ -175,7 +153,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
createdAt: post.createdAt,
|
||||
likesCount: post.likesCount || 0,
|
||||
repostsCount: post.repostsCount || 0,
|
||||
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0),
|
||||
repliesCount: post.repliesCount || 0,
|
||||
author,
|
||||
media: post.media || [],
|
||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||
|
||||
@@ -46,6 +46,8 @@ export default function EditBotPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sourceError, setSourceError] = useState('');
|
||||
const [validatingSource, setValidatingSource] = useState(false);
|
||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -80,6 +82,12 @@ export default function EditBotPage() {
|
||||
fetchBot();
|
||||
}, [botId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceError) {
|
||||
setSourceError('');
|
||||
}
|
||||
}, [newSource]);
|
||||
|
||||
const fetchBot = async () => {
|
||||
try {
|
||||
const [botRes, sourcesRes] = await Promise.all([
|
||||
@@ -147,46 +155,94 @@ export default function EditBotPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSource = () => {
|
||||
// Validate based on type
|
||||
if (newSource.type === 'brave_news') {
|
||||
if (!newSource.braveQuery || !newSource.apiKey) return;
|
||||
// Build URL from config
|
||||
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');
|
||||
url.searchParams.set('q', newSource.braveQuery);
|
||||
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
|
||||
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
|
||||
setSources([...sources, { ...newSource, url: url.toString() }]);
|
||||
} else if (newSource.type === 'news_api') {
|
||||
if (!newSource.newsQuery || !newSource.apiKey) return;
|
||||
// Build URL from config
|
||||
url.searchParams.set('q', source.braveQuery);
|
||||
if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness);
|
||||
if (source.braveCountry) url.searchParams.set('country', source.braveCountry);
|
||||
payload.url = url.toString();
|
||||
payload.braveNewsConfig = {
|
||||
query: source.braveQuery,
|
||||
freshness: source.braveFreshness,
|
||||
country: source.braveCountry || undefined,
|
||||
};
|
||||
} else if (source.type === 'news_api' && source.newsQuery) {
|
||||
let baseUrl: string;
|
||||
const params = new URLSearchParams();
|
||||
switch (newSource.newsProvider) {
|
||||
switch (source.newsProvider) {
|
||||
case 'gnews':
|
||||
baseUrl = 'https://gnews.io/api/v4/search';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
||||
params.set('q', source.newsQuery);
|
||||
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||
if (source.newsLanguage) params.set('lang', source.newsLanguage);
|
||||
if (source.newsCategory) params.set('topic', source.newsCategory);
|
||||
break;
|
||||
case 'newsdata':
|
||||
baseUrl = 'https://newsdata.io/api/1/news';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
||||
params.set('q', source.newsQuery);
|
||||
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||
if (source.newsCategory) params.set('category', source.newsCategory);
|
||||
break;
|
||||
default:
|
||||
baseUrl = 'https://newsapi.org/v2/everything';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
params.set('q', source.newsQuery);
|
||||
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||
}
|
||||
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
|
||||
} else {
|
||||
if (!newSource.url) return;
|
||||
setSources([...sources, { ...newSource }]);
|
||||
payload.url = `${baseUrl}?${params.toString()}`;
|
||||
payload.newsApiConfig = {
|
||||
provider: source.newsProvider,
|
||||
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({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
@@ -695,11 +751,17 @@ export default function EditBotPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceError && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
|
||||
{sourceError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSource}
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
disabled={validatingSource ||
|
||||
(newSource.type === 'rss' && !newSource.url) ||
|
||||
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
||||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
||||
@@ -707,7 +769,7 @@ export default function EditBotPage() {
|
||||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
||||
}
|
||||
>
|
||||
Add Source
|
||||
{validatingSource ? 'Validating Source...' : 'Add Source'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -205,7 +205,8 @@ export default function BotDetailPage() {
|
||||
fetchSources();
|
||||
} else {
|
||||
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) {
|
||||
showToast('Failed to add source', 'error');
|
||||
|
||||
+91
-29
@@ -39,6 +39,8 @@ export default function NewBotPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [sourceError, setSourceError] = useState('');
|
||||
const [validatingSource, setValidatingSource] = useState(false);
|
||||
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -98,46 +100,100 @@ export default function NewBotPage() {
|
||||
fetchPreviousBotSettings();
|
||||
}, []);
|
||||
|
||||
const handleAddSource = () => {
|
||||
// Validate based on type
|
||||
if (newSource.type === 'brave_news') {
|
||||
if (!newSource.braveQuery || !newSource.apiKey) return;
|
||||
// Build URL from config
|
||||
useEffect(() => {
|
||||
if (sourceError) {
|
||||
setSourceError('');
|
||||
}
|
||||
}, [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');
|
||||
url.searchParams.set('q', newSource.braveQuery);
|
||||
if (newSource.braveFreshness) url.searchParams.set('freshness', newSource.braveFreshness);
|
||||
if (newSource.braveCountry) url.searchParams.set('country', newSource.braveCountry);
|
||||
setSources([...sources, { ...newSource, url: url.toString() }]);
|
||||
} else if (newSource.type === 'news_api') {
|
||||
if (!newSource.newsQuery || !newSource.apiKey) return;
|
||||
// Build URL from config
|
||||
url.searchParams.set('q', source.braveQuery);
|
||||
if (source.braveFreshness) url.searchParams.set('freshness', source.braveFreshness);
|
||||
if (source.braveCountry) url.searchParams.set('country', source.braveCountry);
|
||||
payload.url = url.toString();
|
||||
payload.braveNewsConfig = {
|
||||
query: source.braveQuery,
|
||||
freshness: source.braveFreshness,
|
||||
country: source.braveCountry || undefined,
|
||||
};
|
||||
} else if (source.type === 'news_api' && source.newsQuery) {
|
||||
let baseUrl: string;
|
||||
const params = new URLSearchParams();
|
||||
switch (newSource.newsProvider) {
|
||||
switch (source.newsProvider) {
|
||||
case 'gnews':
|
||||
baseUrl = 'https://gnews.io/api/v4/search';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('lang', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('topic', newSource.newsCategory);
|
||||
params.set('q', source.newsQuery);
|
||||
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||
if (source.newsLanguage) params.set('lang', source.newsLanguage);
|
||||
if (source.newsCategory) params.set('topic', source.newsCategory);
|
||||
break;
|
||||
case 'newsdata':
|
||||
baseUrl = 'https://newsdata.io/api/1/news';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsCountry) params.set('country', newSource.newsCountry);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
if (newSource.newsCategory) params.set('category', newSource.newsCategory);
|
||||
params.set('q', source.newsQuery);
|
||||
if (source.newsCountry) params.set('country', source.newsCountry);
|
||||
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||
if (source.newsCategory) params.set('category', source.newsCategory);
|
||||
break;
|
||||
default:
|
||||
baseUrl = 'https://newsapi.org/v2/everything';
|
||||
params.set('q', newSource.newsQuery);
|
||||
if (newSource.newsLanguage) params.set('language', newSource.newsLanguage);
|
||||
params.set('q', source.newsQuery);
|
||||
if (source.newsLanguage) params.set('language', source.newsLanguage);
|
||||
}
|
||||
setSources([...sources, { ...newSource, url: `${baseUrl}?${params.toString()}` }]);
|
||||
} else {
|
||||
if (!newSource.url) return;
|
||||
setSources([...sources, { ...newSource }]);
|
||||
payload.url = `${baseUrl}?${params.toString()}`;
|
||||
payload.newsApiConfig = {
|
||||
provider: source.newsProvider,
|
||||
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({
|
||||
type: 'rss',
|
||||
url: '',
|
||||
@@ -648,11 +704,17 @@ export default function NewBotPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceError && (
|
||||
<div style={{ fontSize: '13px', color: 'var(--error)' }}>
|
||||
{sourceError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSource}
|
||||
className="btn btn-primary"
|
||||
disabled={
|
||||
disabled={validatingSource ||
|
||||
(newSource.type === 'rss' && !newSource.url) ||
|
||||
(newSource.type === 'reddit' && !newSource.subreddit) ||
|
||||
(newSource.type === 'youtube' && !newSource.youtubeChannelId && !newSource.youtubePlaylistId) ||
|
||||
@@ -660,7 +722,7 @@ export default function NewBotPage() {
|
||||
(newSource.type === 'news_api' && (!newSource.newsQuery || !newSource.apiKey))
|
||||
}
|
||||
>
|
||||
Add Source
|
||||
{validatingSource ? 'Validating Source...' : 'Add Source'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+16
-5
@@ -4,7 +4,8 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
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';
|
||||
|
||||
interface Conversation {
|
||||
@@ -392,10 +393,20 @@ export default function ChatPage() {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, 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
|
||||
href={getProfilePath(selectedConversation.participant2.handle)}
|
||||
style={{
|
||||
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>
|
||||
<button
|
||||
onClick={() => { setConversationToDelete(selectedConversation); setShowDeleteModal(true); }}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { getProfilePath } from '@/lib/utils/handle';
|
||||
|
||||
type AdminUser = {
|
||||
id: string;
|
||||
@@ -440,7 +441,7 @@ export default function ModerationPage() {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
</Link>
|
||||
{user.isSuspended ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { BellIcon } from '@/components/Icons';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { getProfilePath } from '@/lib/utils/handle';
|
||||
|
||||
interface NotificationActor {
|
||||
id: string;
|
||||
@@ -183,6 +184,7 @@ function NotificationItem({
|
||||
}) {
|
||||
const isUnread = !notification.readAt;
|
||||
const actor = notification.actor;
|
||||
const actorProfilePath = actor ? getProfilePath(actor.handle) : '#';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -195,7 +197,7 @@ function NotificationItem({
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Link href={actor ? `/@${actor.handle}` : '#'} style={{ flexShrink: 0 }}>
|
||||
<Link href={actorProfilePath} style={{ flexShrink: 0 }}>
|
||||
{actor?.avatarUrl ? (
|
||||
<img
|
||||
src={actor.avatarUrl}
|
||||
@@ -227,7 +229,7 @@ function NotificationItem({
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Link
|
||||
href={actor ? `/@${actor.handle}` : '#'}
|
||||
href={actorProfilePath}
|
||||
style={{ fontWeight: 600, color: 'var(--foreground)', textDecoration: 'none' }}
|
||||
>
|
||||
{actor?.displayName || actor?.handle || 'Someone'} <span style={{ fontWeight: 400, color: 'var(--foreground-tertiary)' }}>@{actor?.handle}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
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 { Post } from '@/lib/types';
|
||||
import { Bot } from 'lucide-react';
|
||||
@@ -70,7 +70,7 @@ function UserCard({ user }: { user: User }) {
|
||||
const fullHandle = useFormattedHandle(user.handle);
|
||||
return (
|
||||
<Link
|
||||
href={`/@${user.handle}`}
|
||||
href={getProfilePath(user.handle)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getProfilePath } from '@/lib/utils/handle';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { UserX, Globe, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -179,7 +180,7 @@ export default function ModerationSettingsPage() {
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={`/@${user.handle}`}
|
||||
href={getProfilePath(user.handle)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
Reference in New Issue
Block a user