diff --git a/src/app/[handle]/page.tsx b/src/app/[handle]/page.tsx index ec68dd3..3187537 100644 --- a/src/app/[handle]/page.tsx +++ b/src/app/[handle]/page.tsx @@ -85,11 +85,13 @@ export default function ProfilePage() { const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null); const [isFollowing, setIsFollowing] = useState(false); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState<'posts' | 'likes' | 'followers' | 'following'>('posts'); + const [activeTab, setActiveTab] = useState<'posts' | 'replies' | 'likes' | 'followers' | 'following'>('posts'); const [followers, setFollowers] = useState([]); const [following, setFollowing] = useState([]); + const [repliesPosts, setRepliesPosts] = useState([]); const [postsLoading, setPostsLoading] = useState(true); const [likesLoading, setLikesLoading] = useState(false); + const [repliesLoading, setRepliesLoading] = useState(false); const [followersLoading, setFollowersLoading] = useState(false); const [followingLoading, setFollowingLoading] = useState(false); const [isEditing, setIsEditing] = useState(false); @@ -111,6 +113,7 @@ export default function ProfilePage() { setFollowers([]); setFollowing([]); setLikedPosts([]); + setRepliesPosts([]); // Get current user fetch('/api/auth/me') .then(res => res.json()) @@ -217,7 +220,16 @@ export default function ProfilePage() { .catch(() => setLikedPosts([])) .finally(() => setLikesLoading(false)); } - }, [activeTab, handle]); + + if (activeTab === 'replies' && user) { + setRepliesLoading(true); + fetch(`/api/posts?type=replies&userId=${user.id}`) + .then(res => res.json()) + .then(data => setRepliesPosts(data.posts || [])) + .catch(() => setRepliesPosts([])) + .finally(() => setRepliesLoading(false)); + } + }, [activeTab, handle, user]); const handleFollow = async () => { if (!currentUser) return; @@ -718,8 +730,8 @@ export default function ProfilePage() { {/* Tabs */}
{(user?.isBot - ? ['posts', 'followers', 'following'] as const - : ['posts', 'likes', 'followers', 'following'] as const + ? ['posts', 'replies', 'followers', 'following'] as const + : ['posts', 'replies', 'likes', 'followers', 'following'] as const ).map(tab => (
+ ) : repliesPosts.length === 0 ? ( +
+

No replies yet

+
+ ) : ( + repliesPosts.map((post, index) => ( + + )) + ) + )} + {activeTab === 'likes' && ( likesLoading ? (
diff --git a/src/app/[handle]/posts/[id]/page.tsx b/src/app/[handle]/posts/[id]/page.tsx index af72954..22f17b0 100644 --- a/src/app/[handle]/posts/[id]/page.tsx +++ b/src/app/[handle]/posts/[id]/page.tsx @@ -165,6 +165,7 @@ export default function PostDetailPage() { onLike={handleLike} onRepost={handleRepost} onDelete={handleDelete} + parentPostAuthorId={post.author.id} onComment={(p) => { // In detail view, commenting on a reply should probably just focus the main composer // but we could also implement nested replies later. diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index db6084d..323c80a 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -255,6 +255,8 @@ export async function DELETE( const user = await requireAuth(); const { id } = await params; + const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; + const post = await db.query.posts.findFirst({ where: eq(posts.id, id), with: { @@ -267,10 +269,22 @@ export async function DELETE( } // Allow deletion if user owns the post OR if user owns the bot that made the post + // OR if user owns the parent post (can delete replies on their posts) const isPostOwner = post.userId === user.id; const isBotOwner = post.bot && post.bot.ownerId === user.id; - if (!isPostOwner && !isBotOwner) { + // Check if user owns the parent post (for deleting replies on their posts) + let isParentPostOwner = false; + if (post.replyToId) { + const parentPost = await db.query.posts.findFirst({ + where: eq(posts.id, post.replyToId), + }); + if (parentPost && parentPost.userId === user.id) { + isParentPostOwner = true; + } + } + + if (!isPostOwner && !isBotOwner && !isParentPostOwner) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); } @@ -286,10 +300,43 @@ export async function DELETE( } } - // 2. Delete the post (cascades to media, likes, notifications) + // 2. If this is a reply to a swarm post, notify the origin node to delete it + if (post.swarmReplyToId) { + const parts = post.swarmReplyToId.split(':'); + if (parts.length >= 3) { + const originDomain = parts[1]; + + // Fire and forget - don't block on this + (async () => { + try { + const protocol = originDomain.includes('localhost') ? 'http' : 'https'; + const res = await fetch(`${protocol}://${originDomain}/api/swarm/replies`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + replyId: post.id, + nodeDomain, + authorHandle: user.handle, + }), + signal: AbortSignal.timeout(5000), + }); + + if (res.ok) { + console.log(`[Swarm] Deletion propagated to ${originDomain}`); + } else { + console.error(`[Swarm] Failed to propagate deletion: ${res.status}`); + } + } catch (err) { + console.error('[Swarm] Error propagating deletion:', err); + } + })(); + } + } + + // 3. Delete the post (cascades to media, likes, notifications) await db.delete(posts).where(eq(posts.id, id)); - // 3. Decrement the post author's postsCount + // 4. Decrement the post author's postsCount const postAuthor = await db.query.users.findFirst({ where: eq(users.id, post.userId), }); diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index ea2a5f6..86e1952 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db'; import { requireAuth } from '@/lib/auth'; -import { eq, desc, and, inArray, isNull, notInArray, or } from 'drizzle-orm'; +import { eq, desc, and, inArray, isNull, isNotNull, notInArray, or } from 'drizzle-orm'; import type { SQL } from 'drizzle-orm'; import { z } from 'zod'; @@ -375,14 +375,25 @@ export async function GET(request: Request) { } const { searchParams } = new URL(request.url); - const type = searchParams.get('type') || 'home'; // home, public, user, curated + const type = searchParams.get('type') || 'home'; // home, public, user, curated, replies const userId = searchParams.get('userId'); const cursor = searchParams.get('cursor'); const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50); let feedPosts; + // Base filter excludes removed posts and replies (replies only show on detail/profile) const baseFilter = buildWhere( - eq(posts.isRemoved, false) + eq(posts.isRemoved, false), + isNull(posts.replyToId), + isNull(posts.swarmReplyToId) + ); + // Filter for replies only + const repliesFilter = buildWhere( + eq(posts.isRemoved, false), + or( + isNotNull(posts.replyToId), + isNotNull(posts.swarmReplyToId) + ) ); if (type === 'local') { @@ -429,7 +440,7 @@ export async function GET(request: Request) { .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, limit) as any; } else if (type === 'user' && userId) { - // User's posts + // User's posts (excluding replies) feedPosts = await db.query.posts.findMany({ where: buildWhere(baseFilter, eq(posts.userId, userId)), with: { @@ -443,6 +454,21 @@ export async function GET(request: Request) { orderBy: [desc(posts.createdAt)], limit, }); + } else if (type === 'replies' && userId) { + // User's replies only + feedPosts = await db.query.posts.findMany({ + where: buildWhere(repliesFilter, eq(posts.userId, userId)), + with: { + author: true, + bot: true, + media: true, + replyTo: { + with: { author: true, media: true }, + }, + }, + orderBy: [desc(posts.createdAt)], + limit, + }); } else if (type === 'curated') { // Curated feed - swarm posts only (no fediverse) let viewer = null; diff --git a/src/app/api/swarm/replies/route.ts b/src/app/api/swarm/replies/route.ts index 0b1bac8..75268d4 100644 --- a/src/app/api/swarm/replies/route.ts +++ b/src/app/api/swarm/replies/route.ts @@ -112,6 +112,60 @@ export async function POST(request: NextRequest) { } } +/** + * DELETE /api/swarm/replies + * + * Receives a deletion request from another node. + * Removes a reply that was previously delivered. + */ +export async function DELETE(request: NextRequest) { + try { + if (!db) { + return NextResponse.json({ error: 'Database not available' }, { status: 503 }); + } + + const body = await request.json(); + const { replyId, nodeDomain, authorHandle } = body; + + if (!replyId || !nodeDomain) { + return NextResponse.json({ error: 'replyId and nodeDomain required' }, { status: 400 }); + } + + // Find the reply by its swarm ID + const swarmReplyId = `swarm:${nodeDomain}:${replyId}`; + const existingReply = await db.query.posts.findFirst({ + where: eq(posts.apId, swarmReplyId), + }); + + if (!existingReply) { + // Already deleted or never existed + 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)); + } + } + + // Delete the reply + await db.delete(posts).where(eq(posts.id, existingReply.id)); + + console.log(`[Swarm] Deleted reply ${swarmReplyId} from ${nodeDomain}`); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('[Swarm] Delete reply error:', error); + return NextResponse.json({ error: 'Failed to delete reply' }, { status: 500 }); + } +} + /** * GET /api/swarm/replies?postId=xxx * diff --git a/src/app/globals.css b/src/app/globals.css index b3a682d..2a69737 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -273,34 +273,46 @@ a.btn-primary:visited { /* Thread Container */ .thread-container { position: relative; + border-bottom: none; } -.thread-container .post { +.thread-container > .post { border-bottom: none; - padding-bottom: 8px; + padding-bottom: 0; +} + +.thread-container > .post .post-actions { + display: none; } .thread-line { position: absolute; - left: 36px; - top: 56px; - bottom: 0; + left: 35px; width: 2px; background: var(--border); + top: 52px; + bottom: -16px; + z-index: 1; } .post.thread-parent { - opacity: 0.85; -} - -.post.thread-parent .post-actions { - display: none; + border-bottom: none; + padding-bottom: 4px; } .post.thread-parent .post-content { + color: var(--foreground-secondary); font-size: 14px; } +.post.thread-parent .post-header { + margin-bottom: 4px; +} + +.post.thread-parent + .post { + padding-top: 8px; +} + /* Post */ .post { padding: 16px; diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 52a41f8..b6d37cf 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons'; import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react'; import { Post } from '@/lib/types'; @@ -38,11 +39,13 @@ interface PostCardProps { isDetail?: boolean; showThread?: boolean; // Show parent post inline as a thread isThreadParent?: boolean; // This post is being shown as a parent in a thread + parentPostAuthorId?: string; // ID of the parent post's author (for allowing deletion of replies) } -export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent }: PostCardProps) { +export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, isDetail, showThread = true, isThreadParent, parentPostAuthorId }: PostCardProps) { const { user: currentUser } = useAuth(); const { showToast } = useToast(); + const router = useRouter(); const [liked, setLiked] = useState(post.isLiked || false); const [reposted, setReposted] = useState(post.isReposted || false); const [reporting, setReporting] = useState(false); @@ -101,7 +104,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, const handleComment = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - onComment?.(post); + // Navigate to post detail page + router.push(postUrl); }; const handleReport = async (e: React.MouseEvent) => { @@ -375,10 +379,36 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, : post.swarmReplyToAuthor)?.nodeDomain, } as Post : null); + // If this is a thread parent being rendered, just render the article + if (isThreadParent) { + return ( +
+
+ e.stopPropagation()}> +
+ {post.author.avatarUrl ? ( + {post.author.displayName + ) : ( + post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase() + )} +
+ +
+ e.stopPropagation()}> + {post.author.displayName || post.author.handle} + + {formatFullHandle(post.author.handle, post.nodeDomain)} +
+
+
{renderContent(post.content, post.linkPreviewUrl ?? undefined)}
+
+ ); + } + return ( <> - {/* Show parent post as part of thread */} - {showThread && effectiveReplyTo && !isDetail && !isThreadParent && ( + {/* Show parent post as part of thread - only on detail page */} + {showThread && effectiveReplyTo && isDetail && (
)} -
+
{!isDetail && }
@@ -629,7 +659,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, {reporting ? '...' : ''} - {(currentUser?.id === post.author.id || (post.bot && currentUser?.id === post.bot.ownerId)) && ( + {(currentUser?.id === post.author.id || (post.bot && currentUser?.id === post.bot.ownerId) || (parentPostAuthorId && currentUser?.id === parentPostAuthorId)) && (