diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 53eff47..d81c68d 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -40,6 +40,8 @@ export async function GET() { did: session.user.did, publicKey: session.user.publicKey, privateKeyEncrypted: session.user.privateKeyEncrypted, + isNsfw: session.user.isNsfw, + nsfwEnabled: session.user.nsfwEnabled, }, accounts, }); diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index de46421..bb91dfd 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -1,7 +1,23 @@ import { NextResponse } from 'next/server'; +import { db } from '@/db'; export async function GET() { + const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821'; + let isNsfw = false; + + try { + let node = await db.query.nodes.findFirst({ where: { domain } }); + if (!node) { + const nodes = await db.query.nodes.findMany({ limit: 2 }); + if (nodes.length === 1) node = nodes[0]; + } + isNsfw = node?.isNsfw === true; + } catch (error) { + console.error('Runtime config lookup failed:', error); + } + return NextResponse.json({ - domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821', + domain, + isNsfw, }); } diff --git a/src/app/api/node/route.ts b/src/app/api/node/route.ts index 5b528f9..a462737 100644 --- a/src/app/api/node/route.ts +++ b/src/app/api/node/route.ts @@ -34,13 +34,14 @@ export async function GET() { .map(e => e.trim().toLowerCase()) .filter(Boolean); - let admins: { handle: string; displayName: string | null; avatarUrl: string | null }[] = []; + let admins: { handle: string; displayName: string | null; avatarUrl: string | null; isNsfw: boolean }[] = []; if (adminEmails.length > 0) { const adminUsers = await db .select({ handle: users.handle, displayName: users.displayName, avatarUrl: users.avatarUrl, + isNsfw: users.isNsfw, }) .from(users) .where(inArray(users.email, adminEmails)); @@ -56,6 +57,7 @@ export async function GET() { publicKey, admins, turnstileSiteKey: null, + isNsfw: false, }); } diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 64ba7dc..c7a74f9 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -746,6 +746,8 @@ export async function GET(request: Request) { avatarUrl: sp.author.avatarUrl, isSwarm: true, isBot: sp.author.isBot, + isNsfw: sp.author.isNsfw, + nodeIsNsfw: sp.nodeIsNsfw, nodeDomain: sp.nodeDomain, }, media: sp.media?.map((m, idx) => ({ diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 2f9f859..89ea68b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -33,6 +33,8 @@ type SearchUser = { profileUrl?: string | null; isRemote?: boolean; isBot?: boolean; + isNsfw?: boolean; + nodeIsNsfw?: boolean; }; const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => { @@ -102,6 +104,7 @@ export async function GET(request: Request) { avatarUrl: users.avatarUrl, bio: users.bio, isBot: users.isBot, + isNsfw: users.isNsfw, }) .from(users) .where(and( @@ -133,6 +136,7 @@ export async function GET(request: Request) { avatarUrl: users.avatarUrl, bio: users.bio, isBot: users.isBot, + isNsfw: users.isNsfw, }) .from(users) .where(userConditions) @@ -168,6 +172,8 @@ export async function GET(request: Request) { profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`, isRemote: true, isBot: profileData.profile.isBot, + isNsfw: profileData.profile.isNsfw, + nodeIsNsfw: profileData.profile.nodeIsNsfw, }; if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) { searchUsers = [remoteUser, ...searchUsers].slice(0, limit); diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts index 7a0f582..15cd7ef 100644 --- a/src/app/api/swarm/users/[handle]/route.ts +++ b/src/app/api/swarm/users/[handle]/route.ts @@ -21,6 +21,8 @@ export interface SwarmUserProfile { postsCount: number; createdAt: string; isBot?: boolean; + isNsfw: boolean; + nodeIsNsfw: boolean; botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain") nodeDomain: string; publicKey?: string; // Signing key for verifying actions @@ -42,6 +44,8 @@ export interface SwarmUserPost { displayName?: string; avatarUrl?: string; isBot?: boolean; + isNsfw?: boolean; + nodeIsNsfw?: boolean; nodeDomain?: string; }; media?: { url: string; mimeType?: string; altText?: string }[]; @@ -81,7 +85,7 @@ function parseMediaJson(mediaJson: string | null) { } } -function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost { +function mapLocalPostToSwarmPost(post: any, nodeDomain: string, nodeIsNsfw: boolean): SwarmUserPost { return { id: post.id, originalPostId: post.id, @@ -97,6 +101,8 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost { displayName: post.author.displayName || post.author.handle, avatarUrl: post.author.avatarUrl || undefined, isBot: post.author.isBot || undefined, + isNsfw: post.author.isNsfw, + nodeIsNsfw, nodeDomain, } : undefined, media: (post.media || []).map((item: any) => ({ @@ -112,14 +118,15 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost { linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined, linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson), repostOfId: post.repostOfId || undefined, - repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined, + repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain, nodeIsNsfw) : undefined, }; } function mapUserSwarmRepostToSwarmPost( row: typeof userSwarmReposts.$inferSelect, author: typeof users.$inferSelect, - nodeDomain: string + nodeDomain: string, + nodeIsNsfw: boolean ): SwarmUserPost { return { id: row.id, @@ -136,6 +143,8 @@ function mapUserSwarmRepostToSwarmPost( displayName: author.displayName || author.handle, avatarUrl: author.avatarUrl || undefined, isBot: author.isBot || undefined, + isNsfw: author.isNsfw, + nodeIsNsfw, nodeDomain, }, repostOfId: row.originalPostId, @@ -153,6 +162,7 @@ function mapUserSwarmRepostToSwarmPost( handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`, displayName: row.authorDisplayName || row.authorHandle, avatarUrl: row.authorAvatarUrl || undefined, + nodeIsNsfw: false, nodeDomain: row.nodeDomain, }, media: parseMediaJson(row.mediaJson), @@ -185,6 +195,12 @@ export async function GET(request: NextRequest, context: RouteContext) { } const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; + let node = await db.query.nodes.findFirst({ where: { domain: nodeDomain } }); + if (!node) { + const localNodes = await db.query.nodes.findMany({ limit: 2 }); + if (localNodes.length === 1) node = localNodes[0]; + } + const nodeIsNsfw = node?.isNsfw === true; // Find the user const user = await db.query.users.findFirst({ @@ -215,6 +231,8 @@ export async function GET(request: NextRequest, context: RouteContext) { postsCount: user.postsCount, createdAt: user.createdAt.toISOString(), isBot: user.isBot || undefined, + isNsfw: user.isNsfw, + nodeIsNsfw, botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined, nodeDomain, publicKey: user.publicKey, // Expose signing key @@ -235,8 +253,8 @@ export async function GET(request: NextRequest, context: RouteContext) { }); const swarmPosts: SwarmUserPost[] = [ - ...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)), - ...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)), + ...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain, nodeIsNsfw)), + ...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain, nodeIsNsfw)), ] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, limit); diff --git a/src/app/api/users/[handle]/route.ts b/src/app/api/users/[handle]/route.ts index d5edcb0..d4405d4 100644 --- a/src/app/api/users/[handle]/route.ts +++ b/src/app/api/users/[handle]/route.ts @@ -96,6 +96,8 @@ export async function GET(request: Request, context: RouteContext) { avatarUrl: null, } : null, did: profile.did, + isNsfw: profile.isNsfw, + nodeIsNsfw: profile.nodeIsNsfw, } }); } @@ -131,6 +133,7 @@ export async function GET(request: Request, context: RouteContext) { publicKey: user.publicKey, // Signing key did: user.did, // V2 Identity dmPrivacy: user.dmPrivacy, + isNsfw: user.isNsfw, }; // Check if viewer can DM this user diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 7f48d88..0fb71d3 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -21,6 +21,7 @@ export async function GET(request: NextRequest) { avatarUrl: users.avatarUrl, createdAt: users.createdAt, isBot: users.isBot, + isNsfw: users.isNsfw, }) .from(users) .where(sql`${users.isSuspended} IS FALSE AND ${users.handle} NOT LIKE '%@%'`) diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index c571a52..10bb962 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -20,6 +20,8 @@ interface User { profileUrl?: string | null; isRemote?: boolean; isBot?: boolean; + isNsfw?: boolean; + nodeIsNsfw?: boolean; } function UserCard({ user }: { user: User }) { @@ -27,7 +29,7 @@ function UserCard({ user }: { user: User }) { return (
- +
@@ -66,8 +68,10 @@ interface SwarmPost { handle: string; displayName: string; avatarUrl?: string; + isNsfw: boolean; }; nodeDomain: string; + nodeIsNsfw: boolean; likeCount: number; repostCount: number; replyCount: number; @@ -453,6 +457,8 @@ export default function ExplorePage() { handle: post.author.handle, displayName: post.author.displayName, avatarUrl: post.author.avatarUrl, + isNsfw: post.author.isNsfw, + nodeIsNsfw: post.nodeIsNsfw, }, media: post.media?.map((m, idx) => ({ id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`, diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index f1e3714..06f7c5d 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -20,6 +20,8 @@ interface User { profileUrl?: string | null; isRemote?: boolean; isBot?: boolean; + isNsfw?: boolean; + nodeIsNsfw?: boolean; } @@ -83,7 +85,7 @@ function UserCard({ user }: { user: User }) { className="hover-bg" >
- +
diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index fd78422..2690130 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -15,6 +15,7 @@ import { useAuth } from '@/lib/contexts/AuthContext'; import { hasUnsavedChanges } from '@/lib/forms/dirty-state'; import { signedAPI } from '@/lib/api/signed-fetch'; import { AvatarImage } from '@/components/AvatarImage'; +import { ProfileBanner } from '@/components/ProfileBanner'; interface BotOwner { id: string; @@ -30,6 +31,8 @@ interface UserSummary { bio?: string | null; avatarUrl?: string | null; isBot?: boolean; + isNsfw?: boolean; + nodeIsNsfw?: boolean; } type ProfileMediaField = 'avatarUrl' | 'headerUrl'; @@ -45,7 +48,7 @@ function UserRow({ user }: { user: UserSummary }) { return (
- +
@@ -579,17 +582,12 @@ export default function ProfilePage() {
{/* Banner */} {/* Banner */} -
-
+ {/* Avatar & Actions */}
@@ -609,7 +607,7 @@ export default function ProfilePage() { position: 'relative', }} > - +
diff --git a/src/components/AvatarImage.tsx b/src/components/AvatarImage.tsx index accca3d..e5f8273 100644 --- a/src/components/AvatarImage.tsx +++ b/src/components/AvatarImage.tsx @@ -1,6 +1,9 @@ 'use client'; import { useState, type ImgHTMLAttributes } from 'react'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; +import { shouldBlurProfileMedia } from '@/lib/nsfw/profile-media'; export function getDiceBearAvatarUrl(seed: string): string { return `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${encodeURIComponent(seed)}`; @@ -9,18 +12,32 @@ export function getDiceBearAvatarUrl(seed: string): string { interface AvatarImageProps extends Omit, 'src'> { avatarUrl?: string | null; seed: string; + isNsfw?: boolean; + nodeIsNsfw?: boolean; } -export function AvatarImage({ avatarUrl, seed, alt = '', onError, ...props }: AvatarImageProps) { +export function AvatarImage({ avatarUrl, seed, isNsfw = false, nodeIsNsfw, alt = '', onError, style, ...props }: AvatarImageProps) { const [failedAvatarUrl, setFailedAvatarUrl] = useState(null); + const { user } = useAuth(); + const { config } = useRuntimeConfig(); const customAvatar = avatarUrl?.trim(); const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed); + const blurred = shouldBlurProfileMedia({ + accountIsNsfw: isNsfw, + nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false, + viewer: user, + }); return ( {alt} { onError?.(event); if (customAvatar && failedAvatarUrl !== customAvatar) setFailedAvatarUrl(customAvatar); diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 9f1191f..5075ebe 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -640,7 +640,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation()}>
- +
@@ -718,7 +718,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
e.stopPropagation()}>
- +
diff --git a/src/components/ProfileBanner.tsx b/src/components/ProfileBanner.tsx new file mode 100644 index 0000000..d4f7f4d --- /dev/null +++ b/src/components/ProfileBanner.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useAuth } from '@/lib/contexts/AuthContext'; +import { useRuntimeConfig } from '@/lib/contexts/ConfigContext'; +import { shouldBlurProfileMedia } from '@/lib/nsfw/profile-media'; + +export function ProfileBanner({ + url, + isNsfw = false, + nodeIsNsfw, + height, + aspectRatio, + borderBottom, +}: { + url?: string | null; + isNsfw?: boolean; + nodeIsNsfw?: boolean; + height?: string | number; + aspectRatio?: string; + borderBottom?: string; +}) { + const { user } = useAuth(); + const { config } = useRuntimeConfig(); + const blurred = shouldBlurProfileMedia({ + accountIsNsfw: isNsfw, + nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false, + viewer: user, + }); + + return ( +
+
+
+ ); +} diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index 1d797fe..93e0767 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -3,11 +3,13 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { AvatarImage } from './AvatarImage'; +import { ProfileBanner } from './ProfileBanner'; interface Admin { handle: string; displayName: string | null; avatarUrl: string | null; + isNsfw: boolean; } interface NodeInfo { @@ -17,6 +19,7 @@ interface NodeInfo { rules: string; bannerUrl: string; admins: Admin[]; + isNsfw: boolean; } export function RightSidebar() { @@ -28,6 +31,7 @@ export function RightSidebar() { rules: '', bannerUrl: '', admins: [] as Admin[], + isNsfw: false, }); const [version, setVersion] = useState<{ version: string; @@ -109,12 +113,11 @@ export function RightSidebar() {