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 (
{
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() {