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:
@@ -85,11 +85,13 @@ export default function ProfilePage() {
|
|||||||
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
const [currentUser, setCurrentUser] = useState<{ id: string; handle: string } | null>(null);
|
||||||
const [isFollowing, setIsFollowing] = useState(false);
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
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 [followers, setFollowers] = useState<UserSummary[]>([]);
|
||||||
const [following, setFollowing] = useState<UserSummary[]>([]);
|
const [following, setFollowing] = useState<UserSummary[]>([]);
|
||||||
|
const [repliesPosts, setRepliesPosts] = useState<Post[]>([]);
|
||||||
const [postsLoading, setPostsLoading] = useState(true);
|
const [postsLoading, setPostsLoading] = useState(true);
|
||||||
const [likesLoading, setLikesLoading] = useState(false);
|
const [likesLoading, setLikesLoading] = useState(false);
|
||||||
|
const [repliesLoading, setRepliesLoading] = useState(false);
|
||||||
const [followersLoading, setFollowersLoading] = useState(false);
|
const [followersLoading, setFollowersLoading] = useState(false);
|
||||||
const [followingLoading, setFollowingLoading] = useState(false);
|
const [followingLoading, setFollowingLoading] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -111,6 +113,7 @@ export default function ProfilePage() {
|
|||||||
setFollowers([]);
|
setFollowers([]);
|
||||||
setFollowing([]);
|
setFollowing([]);
|
||||||
setLikedPosts([]);
|
setLikedPosts([]);
|
||||||
|
setRepliesPosts([]);
|
||||||
// Get current user
|
// Get current user
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -217,7 +220,16 @@ export default function ProfilePage() {
|
|||||||
.catch(() => setLikedPosts([]))
|
.catch(() => setLikedPosts([]))
|
||||||
.finally(() => setLikesLoading(false));
|
.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 () => {
|
const handleFollow = async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
@@ -718,8 +730,8 @@ export default function ProfilePage() {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', borderTop: '1px solid var(--border)' }}>
|
||||||
{(user?.isBot
|
{(user?.isBot
|
||||||
? ['posts', 'followers', 'following'] as const
|
? ['posts', 'replies', 'followers', 'following'] as const
|
||||||
: ['posts', 'likes', 'followers', 'following'] as const
|
: ['posts', 'replies', 'likes', 'followers', 'following'] as const
|
||||||
).map(tab => (
|
).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
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' && (
|
{activeTab === 'likes' && (
|
||||||
likesLoading ? (
|
likesLoading ? (
|
||||||
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
<div style={{ padding: '48px', textAlign: 'center', color: 'var(--foreground-tertiary)' }}>
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export default function PostDetailPage() {
|
|||||||
onLike={handleLike}
|
onLike={handleLike}
|
||||||
onRepost={handleRepost}
|
onRepost={handleRepost}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
parentPostAuthorId={post.author.id}
|
||||||
onComment={(p) => {
|
onComment={(p) => {
|
||||||
// In detail view, commenting on a reply should probably just focus the main composer
|
// In detail view, commenting on a reply should probably just focus the main composer
|
||||||
// but we could also implement nested replies later.
|
// but we could also implement nested replies later.
|
||||||
|
|||||||
@@ -255,6 +255,8 @@ export async function DELETE(
|
|||||||
const user = await requireAuth();
|
const user = await requireAuth();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
|
||||||
|
|
||||||
const post = await db.query.posts.findFirst({
|
const post = await db.query.posts.findFirst({
|
||||||
where: eq(posts.id, id),
|
where: eq(posts.id, id),
|
||||||
with: {
|
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
|
// 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 isPostOwner = post.userId === user.id;
|
||||||
const isBotOwner = post.bot && post.bot.ownerId === 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 });
|
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));
|
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({
|
const postAuthor = await db.query.users.findFirst({
|
||||||
where: eq(users.id, post.userId),
|
where: eq(users.id, post.userId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db';
|
import { db, posts, users, media, follows, mutes, blocks, likes, remoteFollows, remotePosts, notifications } from '@/db';
|
||||||
import { requireAuth } from '@/lib/auth';
|
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 type { SQL } from 'drizzle-orm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -375,14 +375,25 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
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 userId = searchParams.get('userId');
|
||||||
const cursor = searchParams.get('cursor');
|
const cursor = searchParams.get('cursor');
|
||||||
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 50);
|
||||||
|
|
||||||
let feedPosts;
|
let feedPosts;
|
||||||
|
// Base filter excludes removed posts and replies (replies only show on detail/profile)
|
||||||
const baseFilter = buildWhere(
|
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') {
|
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())
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
.slice(0, limit) as any;
|
.slice(0, limit) as any;
|
||||||
} else if (type === 'user' && userId) {
|
} else if (type === 'user' && userId) {
|
||||||
// User's posts
|
// User's posts (excluding replies)
|
||||||
feedPosts = await db.query.posts.findMany({
|
feedPosts = await db.query.posts.findMany({
|
||||||
where: buildWhere(baseFilter, eq(posts.userId, userId)),
|
where: buildWhere(baseFilter, eq(posts.userId, userId)),
|
||||||
with: {
|
with: {
|
||||||
@@ -443,6 +454,21 @@ export async function GET(request: Request) {
|
|||||||
orderBy: [desc(posts.createdAt)],
|
orderBy: [desc(posts.createdAt)],
|
||||||
limit,
|
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') {
|
} else if (type === 'curated') {
|
||||||
// Curated feed - swarm posts only (no fediverse)
|
// Curated feed - swarm posts only (no fediverse)
|
||||||
let viewer = null;
|
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
|
* GET /api/swarm/replies?postId=xxx
|
||||||
*
|
*
|
||||||
|
|||||||
+22
-10
@@ -273,34 +273,46 @@ a.btn-primary:visited {
|
|||||||
/* Thread Container */
|
/* Thread Container */
|
||||||
.thread-container {
|
.thread-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-container .post {
|
.thread-container > .post {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-container > .post .post-actions {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-line {
|
.thread-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 36px;
|
left: 35px;
|
||||||
top: 56px;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
|
top: 52px;
|
||||||
|
bottom: -16px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post.thread-parent {
|
.post.thread-parent {
|
||||||
opacity: 0.85;
|
border-bottom: none;
|
||||||
}
|
padding-bottom: 4px;
|
||||||
|
|
||||||
.post.thread-parent .post-actions {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post.thread-parent .post-content {
|
.post.thread-parent .post-content {
|
||||||
|
color: var(--foreground-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post.thread-parent .post-header {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post.thread-parent + .post {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Post */
|
/* Post */
|
||||||
.post {
|
.post {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
@@ -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 { useRouter } from 'next/navigation';
|
||||||
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
import { HeartIcon, RepeatIcon, MessageIcon, FlagIcon, TrashIcon } from '@/components/Icons';
|
||||||
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
|
import { Bot, MoreHorizontal, UserX, VolumeX, Globe } from 'lucide-react';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
@@ -38,11 +39,13 @@ interface PostCardProps {
|
|||||||
isDetail?: boolean;
|
isDetail?: boolean;
|
||||||
showThread?: boolean; // Show parent post inline as a thread
|
showThread?: boolean; // Show parent post inline as a thread
|
||||||
isThreadParent?: boolean; // This post is being shown as a parent in 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 { user: currentUser } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
const [liked, setLiked] = useState(post.isLiked || false);
|
const [liked, setLiked] = useState(post.isLiked || false);
|
||||||
const [reposted, setReposted] = useState(post.isReposted || false);
|
const [reposted, setReposted] = useState(post.isReposted || false);
|
||||||
const [reporting, setReporting] = useState(false);
|
const [reporting, setReporting] = useState(false);
|
||||||
@@ -101,7 +104,8 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
const handleComment = (e: React.MouseEvent) => {
|
const handleComment = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onComment?.(post);
|
// Navigate to post detail page
|
||||||
|
router.push(postUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReport = async (e: React.MouseEvent) => {
|
const handleReport = async (e: React.MouseEvent) => {
|
||||||
@@ -375,10 +379,36 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
: post.swarmReplyToAuthor)?.nodeDomain,
|
: post.swarmReplyToAuthor)?.nodeDomain,
|
||||||
} as Post : null);
|
} as Post : null);
|
||||||
|
|
||||||
|
// If this is a thread parent being rendered, just render the article
|
||||||
|
if (isThreadParent) {
|
||||||
|
return (
|
||||||
|
<article className="post thread-parent">
|
||||||
|
<div className="post-header">
|
||||||
|
<Link href={`/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="avatar">
|
||||||
|
{post.author.avatarUrl ? (
|
||||||
|
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
|
||||||
|
) : (
|
||||||
|
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<div className="post-author">
|
||||||
|
<Link href={`/${profileHandle}`} className="post-handle" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{post.author.displayName || post.author.handle}
|
||||||
|
</Link>
|
||||||
|
<span className="post-time">{formatFullHandle(post.author.handle, post.nodeDomain)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Show parent post as part of thread */}
|
{/* Show parent post as part of thread - only on detail page */}
|
||||||
{showThread && effectiveReplyTo && !isDetail && !isThreadParent && (
|
{showThread && effectiveReplyTo && isDetail && (
|
||||||
<div className="thread-container">
|
<div className="thread-container">
|
||||||
<PostCard
|
<PostCard
|
||||||
post={effectiveReplyTo}
|
post={effectiveReplyTo}
|
||||||
@@ -393,7 +423,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<div className="thread-line" />
|
<div className="thread-line" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<article className={`post ${isDetail ? 'detail' : ''} ${isThreadParent ? 'thread-parent' : ''}`}>
|
<article className={`post ${isDetail ? 'detail' : ''}`}>
|
||||||
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
|
{!isDetail && <Link href={postUrl} className="post-link-overlay" aria-label="View post" />}
|
||||||
|
|
||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
@@ -629,7 +659,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<FlagIcon />
|
<FlagIcon />
|
||||||
<span>{reporting ? '...' : ''}</span>
|
<span>{reporting ? '...' : ''}</span>
|
||||||
</button>
|
</button>
|
||||||
{(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)) && (
|
||||||
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
<button className="post-action delete-action" onClick={handleDelete} disabled={deleting} title="Delete post">
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>{deleting ? '...' : ''}</span>
|
<span>{deleting ? '...' : ''}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user