feat(posts): Add user replies tab and improve reply management
- Add "replies" tab to user profile page to display posts where user replied to others - Implement replies fetching via new `type=replies` query parameter in posts API - Add reply deletion permissions for parent post owners on their own posts - Add swarm reply deletion notification to origin nodes when replies are deleted - Pass `parentPostAuthorId` prop to PostCard for improved reply context tracking - Update profile tab navigation to include replies tab for non-bot users - Add loading and empty states for replies tab display - Improve authorization logic to allow parent post owners to delete replies on their posts
This commit is contained in:
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user