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:
Christomatt
2026-01-26 14:22:32 +01:00
parent 141b99747a
commit a2ec470354
7 changed files with 232 additions and 27 deletions
+39 -4
View File
@@ -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<UserSummary[]>([]);
const [following, setFollowing] = useState<UserSummary[]>([]);
const [repliesPosts, setRepliesPosts] = useState<Post[]>([]);
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 */}
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
{(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 => (
<button
key={tab}
@@ -766,6 +778,29 @@ export default function ProfilePage() {
)
)}
{activeTab === 'replies' && (
repliesLoading ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>Loading...</p>
</div>
) : repliesPosts.length === 0 ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
<p>No replies yet</p>
</div>
) : (
repliesPosts.map((post, index) => (
<PostCard
key={`${post.id}-${index}`}
post={post}
onLike={handleLike}
onRepost={handleRepost}
onComment={handleComment}
onDelete={handleDelete}
/>
))
)
)}
{activeTab === 'likes' && (
likesLoading ? (
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
+1
View File
@@ -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.
+50 -3
View File
@@ -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),
});
+30 -4
View File
@@ -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;
+54
View File
@@ -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
*
+22 -10
View File
@@ -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;