Blur NSFW profile avatars and banners unless the viewer has enabled NSFW
Hop-State: A_06FP9DH64R0TWDEQST5Z460 Hop-Proposal: R_06FP9DF1AKH8Z9GA7AD24G0 Hop-Task: T_06FP9B65YW8Z4VM9386KDB8 Hop-Attempt: AT_06FP9B65YZ94YY9QS6C6Q38
This commit is contained in:
@@ -40,6 +40,8 @@ export async function GET() {
|
|||||||
did: session.user.did,
|
did: session.user.did,
|
||||||
publicKey: session.user.publicKey,
|
publicKey: session.user.publicKey,
|
||||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||||
|
isNsfw: session.user.isNsfw,
|
||||||
|
nsfwEnabled: session.user.nsfwEnabled,
|
||||||
},
|
},
|
||||||
accounts,
|
accounts,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/db';
|
||||||
|
|
||||||
export async function GET() {
|
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({
|
return NextResponse.json({
|
||||||
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821',
|
domain,
|
||||||
|
isNsfw,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ export async function GET() {
|
|||||||
.map(e => e.trim().toLowerCase())
|
.map(e => e.trim().toLowerCase())
|
||||||
.filter(Boolean);
|
.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) {
|
if (adminEmails.length > 0) {
|
||||||
const adminUsers = await db
|
const adminUsers = await db
|
||||||
.select({
|
.select({
|
||||||
handle: users.handle,
|
handle: users.handle,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
avatarUrl: users.avatarUrl,
|
avatarUrl: users.avatarUrl,
|
||||||
|
isNsfw: users.isNsfw,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(inArray(users.email, adminEmails));
|
.where(inArray(users.email, adminEmails));
|
||||||
@@ -56,6 +57,7 @@ export async function GET() {
|
|||||||
publicKey,
|
publicKey,
|
||||||
admins,
|
admins,
|
||||||
turnstileSiteKey: null,
|
turnstileSiteKey: null,
|
||||||
|
isNsfw: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -746,6 +746,8 @@ export async function GET(request: Request) {
|
|||||||
avatarUrl: sp.author.avatarUrl,
|
avatarUrl: sp.author.avatarUrl,
|
||||||
isSwarm: true,
|
isSwarm: true,
|
||||||
isBot: sp.author.isBot,
|
isBot: sp.author.isBot,
|
||||||
|
isNsfw: sp.author.isNsfw,
|
||||||
|
nodeIsNsfw: sp.nodeIsNsfw,
|
||||||
nodeDomain: sp.nodeDomain,
|
nodeDomain: sp.nodeDomain,
|
||||||
},
|
},
|
||||||
media: sp.media?.map((m, idx) => ({
|
media: sp.media?.map((m, idx) => ({
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ type SearchUser = {
|
|||||||
profileUrl?: string | null;
|
profileUrl?: string | null;
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
||||||
@@ -102,6 +104,7 @@ export async function GET(request: Request) {
|
|||||||
avatarUrl: users.avatarUrl,
|
avatarUrl: users.avatarUrl,
|
||||||
bio: users.bio,
|
bio: users.bio,
|
||||||
isBot: users.isBot,
|
isBot: users.isBot,
|
||||||
|
isNsfw: users.isNsfw,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(
|
.where(and(
|
||||||
@@ -133,6 +136,7 @@ export async function GET(request: Request) {
|
|||||||
avatarUrl: users.avatarUrl,
|
avatarUrl: users.avatarUrl,
|
||||||
bio: users.bio,
|
bio: users.bio,
|
||||||
isBot: users.isBot,
|
isBot: users.isBot,
|
||||||
|
isNsfw: users.isNsfw,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(userConditions)
|
.where(userConditions)
|
||||||
@@ -168,6 +172,8 @@ export async function GET(request: Request) {
|
|||||||
profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`,
|
profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`,
|
||||||
isRemote: true,
|
isRemote: true,
|
||||||
isBot: profileData.profile.isBot,
|
isBot: profileData.profile.isBot,
|
||||||
|
isNsfw: profileData.profile.isNsfw,
|
||||||
|
nodeIsNsfw: profileData.profile.nodeIsNsfw,
|
||||||
};
|
};
|
||||||
if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
|
if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
|
||||||
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
|
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface SwarmUserProfile {
|
|||||||
postsCount: number;
|
postsCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw: boolean;
|
||||||
|
nodeIsNsfw: boolean;
|
||||||
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
|
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
|
||||||
nodeDomain: string;
|
nodeDomain: string;
|
||||||
publicKey?: string; // Signing key for verifying actions
|
publicKey?: string; // Signing key for verifying actions
|
||||||
@@ -42,6 +44,8 @@ export interface SwarmUserPost {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
nodeDomain?: string;
|
nodeDomain?: string;
|
||||||
};
|
};
|
||||||
media?: { url: string; mimeType?: string; altText?: 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 {
|
return {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
originalPostId: post.id,
|
originalPostId: post.id,
|
||||||
@@ -97,6 +101,8 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
|
|||||||
displayName: post.author.displayName || post.author.handle,
|
displayName: post.author.displayName || post.author.handle,
|
||||||
avatarUrl: post.author.avatarUrl || undefined,
|
avatarUrl: post.author.avatarUrl || undefined,
|
||||||
isBot: post.author.isBot || undefined,
|
isBot: post.author.isBot || undefined,
|
||||||
|
isNsfw: post.author.isNsfw,
|
||||||
|
nodeIsNsfw,
|
||||||
nodeDomain,
|
nodeDomain,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
media: (post.media || []).map((item: any) => ({
|
media: (post.media || []).map((item: any) => ({
|
||||||
@@ -112,14 +118,15 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
|
|||||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
||||||
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
|
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
|
||||||
repostOfId: post.repostOfId || undefined,
|
repostOfId: post.repostOfId || undefined,
|
||||||
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined,
|
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain, nodeIsNsfw) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapUserSwarmRepostToSwarmPost(
|
function mapUserSwarmRepostToSwarmPost(
|
||||||
row: typeof userSwarmReposts.$inferSelect,
|
row: typeof userSwarmReposts.$inferSelect,
|
||||||
author: typeof users.$inferSelect,
|
author: typeof users.$inferSelect,
|
||||||
nodeDomain: string
|
nodeDomain: string,
|
||||||
|
nodeIsNsfw: boolean
|
||||||
): SwarmUserPost {
|
): SwarmUserPost {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -136,6 +143,8 @@ function mapUserSwarmRepostToSwarmPost(
|
|||||||
displayName: author.displayName || author.handle,
|
displayName: author.displayName || author.handle,
|
||||||
avatarUrl: author.avatarUrl || undefined,
|
avatarUrl: author.avatarUrl || undefined,
|
||||||
isBot: author.isBot || undefined,
|
isBot: author.isBot || undefined,
|
||||||
|
isNsfw: author.isNsfw,
|
||||||
|
nodeIsNsfw,
|
||||||
nodeDomain,
|
nodeDomain,
|
||||||
},
|
},
|
||||||
repostOfId: row.originalPostId,
|
repostOfId: row.originalPostId,
|
||||||
@@ -153,6 +162,7 @@ function mapUserSwarmRepostToSwarmPost(
|
|||||||
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
|
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
|
||||||
displayName: row.authorDisplayName || row.authorHandle,
|
displayName: row.authorDisplayName || row.authorHandle,
|
||||||
avatarUrl: row.authorAvatarUrl || undefined,
|
avatarUrl: row.authorAvatarUrl || undefined,
|
||||||
|
nodeIsNsfw: false,
|
||||||
nodeDomain: row.nodeDomain,
|
nodeDomain: row.nodeDomain,
|
||||||
},
|
},
|
||||||
media: parseMediaJson(row.mediaJson),
|
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';
|
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
|
// Find the user
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
@@ -215,6 +231,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
postsCount: user.postsCount,
|
postsCount: user.postsCount,
|
||||||
createdAt: user.createdAt.toISOString(),
|
createdAt: user.createdAt.toISOString(),
|
||||||
isBot: user.isBot || undefined,
|
isBot: user.isBot || undefined,
|
||||||
|
isNsfw: user.isNsfw,
|
||||||
|
nodeIsNsfw,
|
||||||
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
|
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
|
||||||
nodeDomain,
|
nodeDomain,
|
||||||
publicKey: user.publicKey, // Expose signing key
|
publicKey: user.publicKey, // Expose signing key
|
||||||
@@ -235,8 +253,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const swarmPosts: SwarmUserPost[] = [
|
const swarmPosts: SwarmUserPost[] = [
|
||||||
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
|
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain, nodeIsNsfw)),
|
||||||
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
|
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain, nodeIsNsfw)),
|
||||||
]
|
]
|
||||||
.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);
|
.slice(0, limit);
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
avatarUrl: null,
|
avatarUrl: null,
|
||||||
} : null,
|
} : null,
|
||||||
did: profile.did,
|
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
|
publicKey: user.publicKey, // Signing key
|
||||||
did: user.did, // V2 Identity
|
did: user.did, // V2 Identity
|
||||||
dmPrivacy: user.dmPrivacy,
|
dmPrivacy: user.dmPrivacy,
|
||||||
|
isNsfw: user.isNsfw,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if viewer can DM this user
|
// Check if viewer can DM this user
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
avatarUrl: users.avatarUrl,
|
avatarUrl: users.avatarUrl,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
isBot: users.isBot,
|
isBot: users.isBot,
|
||||||
|
isNsfw: users.isNsfw,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(sql`${users.isSuspended} IS FALSE AND ${users.handle} NOT LIKE '%@%'`)
|
.where(sql`${users.isSuspended} IS FALSE AND ${users.handle} NOT LIKE '%@%'`)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface User {
|
|||||||
profileUrl?: string | null;
|
profileUrl?: string | null;
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserCard({ user }: { user: User }) {
|
function UserCard({ user }: { user: User }) {
|
||||||
@@ -27,7 +29,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
return (
|
return (
|
||||||
<Link href={`/u/${user.handle}`} className="user-card">
|
<Link href={`/u/${user.handle}`} className="user-card">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
|
||||||
</div>
|
</div>
|
||||||
<div className="user-card-info">
|
<div className="user-card-info">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
@@ -66,8 +68,10 @@ interface SwarmPost {
|
|||||||
handle: string;
|
handle: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
isNsfw: boolean;
|
||||||
};
|
};
|
||||||
nodeDomain: string;
|
nodeDomain: string;
|
||||||
|
nodeIsNsfw: boolean;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
repostCount: number;
|
repostCount: number;
|
||||||
replyCount: number;
|
replyCount: number;
|
||||||
@@ -453,6 +457,8 @@ export default function ExplorePage() {
|
|||||||
handle: post.author.handle,
|
handle: post.author.handle,
|
||||||
displayName: post.author.displayName,
|
displayName: post.author.displayName,
|
||||||
avatarUrl: post.author.avatarUrl,
|
avatarUrl: post.author.avatarUrl,
|
||||||
|
isNsfw: post.author.isNsfw,
|
||||||
|
nodeIsNsfw: post.nodeIsNsfw,
|
||||||
},
|
},
|
||||||
media: post.media?.map((m, idx) => ({
|
media: post.media?.map((m, idx) => ({
|
||||||
id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`,
|
id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface User {
|
|||||||
profileUrl?: string | null;
|
profileUrl?: string | null;
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +85,7 @@ function UserCard({ user }: { user: User }) {
|
|||||||
className="hover-bg"
|
className="hover-bg"
|
||||||
>
|
>
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
|||||||
+11
-13
@@ -15,6 +15,7 @@ import { useAuth } from '@/lib/contexts/AuthContext';
|
|||||||
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
||||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
import { AvatarImage } from '@/components/AvatarImage';
|
import { AvatarImage } from '@/components/AvatarImage';
|
||||||
|
import { ProfileBanner } from '@/components/ProfileBanner';
|
||||||
|
|
||||||
interface BotOwner {
|
interface BotOwner {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +31,8 @@ interface UserSummary {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileMediaField = 'avatarUrl' | 'headerUrl';
|
type ProfileMediaField = 'avatarUrl' | 'headerUrl';
|
||||||
@@ -45,7 +48,7 @@ function UserRow({ user }: { user: UserSummary }) {
|
|||||||
return (
|
return (
|
||||||
<Link href={`/u/${user.handle}`} className="user-row">
|
<Link href={`/u/${user.handle}`} className="user-row">
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
|
||||||
</div>
|
</div>
|
||||||
<div className="user-row-content">
|
<div className="user-row-content">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
@@ -579,17 +582,12 @@ export default function ProfilePage() {
|
|||||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<div
|
<ProfileBanner
|
||||||
style={{
|
url={isEditing ? profileForm.headerUrl : user.headerUrl}
|
||||||
width: '100%',
|
isNsfw={user.isNsfw}
|
||||||
aspectRatio: '3 / 1',
|
nodeIsNsfw={user.nodeIsNsfw}
|
||||||
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
aspectRatio="3 / 1"
|
||||||
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
/>
|
||||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Avatar & Actions */}
|
{/* Avatar & Actions */}
|
||||||
<div style={{ padding: '0 16px' }}>
|
<div style={{ padding: '0 16px' }}>
|
||||||
@@ -609,7 +607,7 @@ export default function ProfilePage() {
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarImage avatarUrl={isEditing ? profileForm.avatarUrl : user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
<AvatarImage avatarUrl={isEditing ? profileForm.avatarUrl : user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, type ImgHTMLAttributes } from 'react';
|
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 {
|
export function getDiceBearAvatarUrl(seed: string): string {
|
||||||
return `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${encodeURIComponent(seed)}`;
|
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<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
interface AvatarImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
seed: string;
|
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<string | null>(null);
|
const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null);
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { config } = useRuntimeConfig();
|
||||||
const customAvatar = avatarUrl?.trim();
|
const customAvatar = avatarUrl?.trim();
|
||||||
const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed);
|
const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed);
|
||||||
|
const blurred = shouldBlurProfileMedia({
|
||||||
|
accountIsNsfw: isNsfw,
|
||||||
|
nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false,
|
||||||
|
viewer: user,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
filter: blurred ? 'blur(12px)' : style?.filter,
|
||||||
|
transform: blurred ? 'scale(1.12)' : style?.transform,
|
||||||
|
}}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
onError?.(event);
|
onError?.(event);
|
||||||
if (customAvatar && failedAvatarUrl !== customAvatar) setFailedAvatarUrl(customAvatar);
|
if (customAvatar && failedAvatarUrl !== customAvatar) setFailedAvatarUrl(customAvatar);
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} />
|
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} isNsfw={post.author.isNsfw} nodeIsNsfw={post.author.nodeIsNsfw} alt={post.author.displayName || post.author.handle} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="post-author">
|
<div className="post-author">
|
||||||
@@ -718,7 +718,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
|||||||
<div className="post-header">
|
<div className="post-header">
|
||||||
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="avatar">
|
<div className="avatar">
|
||||||
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} />
|
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} isNsfw={post.author.isNsfw} nodeIsNsfw={post.author.nodeIsNsfw} alt={post.author.displayName || post.author.handle} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="post-author">
|
<div className="post-author">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div style={{ width: '100%', height, aspectRatio, overflow: 'hidden', borderBottom, position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
aria-label={blurred ? 'Sensitive profile banner hidden' : undefined}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: url
|
||||||
|
? `url(${url}) center/cover no-repeat`
|
||||||
|
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||||
|
filter: blurred ? 'blur(20px)' : undefined,
|
||||||
|
transform: blurred ? 'scale(1.12)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { AvatarImage } from './AvatarImage';
|
import { AvatarImage } from './AvatarImage';
|
||||||
|
import { ProfileBanner } from './ProfileBanner';
|
||||||
|
|
||||||
interface Admin {
|
interface Admin {
|
||||||
handle: string;
|
handle: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
isNsfw: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeInfo {
|
interface NodeInfo {
|
||||||
@@ -17,6 +19,7 @@ interface NodeInfo {
|
|||||||
rules: string;
|
rules: string;
|
||||||
bannerUrl: string;
|
bannerUrl: string;
|
||||||
admins: Admin[];
|
admins: Admin[];
|
||||||
|
isNsfw: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
@@ -28,6 +31,7 @@ export function RightSidebar() {
|
|||||||
rules: '',
|
rules: '',
|
||||||
bannerUrl: '',
|
bannerUrl: '',
|
||||||
admins: [] as Admin[],
|
admins: [] as Admin[],
|
||||||
|
isNsfw: false,
|
||||||
});
|
});
|
||||||
const [version, setVersion] = useState<{
|
const [version, setVersion] = useState<{
|
||||||
version: string;
|
version: string;
|
||||||
@@ -109,12 +113,11 @@ export function RightSidebar() {
|
|||||||
<aside className="aside">
|
<aside className="aside">
|
||||||
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
<div className="card" style={{ overflow: 'hidden', padding: 0 }}>
|
||||||
{nodeInfo.bannerUrl && (
|
{nodeInfo.bannerUrl && (
|
||||||
<div
|
<ProfileBanner
|
||||||
style={{
|
url={nodeInfo.bannerUrl}
|
||||||
height: '140px',
|
nodeIsNsfw={nodeInfo.isNsfw}
|
||||||
background: `url(${nodeInfo.bannerUrl}) center/cover no-repeat`,
|
height={140}
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom="1px solid var(--border)"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -175,7 +178,7 @@ export function RightSidebar() {
|
|||||||
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
<AvatarImage avatarUrl={admin.avatarUrl} seed={admin.handle} alt={admin.displayName || admin.handle} />
|
<AvatarImage avatarUrl={admin.avatarUrl} seed={admin.handle} isNsfw={admin.isNsfw} nodeIsNsfw={nodeInfo.isNsfw} alt={admin.displayName || admin.handle} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500, fontSize: '14px' }}>
|
<div style={{ fontWeight: 500, fontSize: '14px' }}>
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export function Sidebar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
<AvatarImage avatarUrl={account.avatarUrl} seed={account.handle} alt={account.displayName || account.handle} />
|
<AvatarImage avatarUrl={account.avatarUrl} seed={account.handle} isNsfw={account.isNsfw} alt={account.displayName || account.handle} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}>
|
<div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
@@ -428,7 +428,7 @@ export function Sidebar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName} />
|
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} alt={user.displayName} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
|
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
|
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export interface AuthAccount {
|
|||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKeyEncrypted: string | null;
|
privateKeyEncrypted: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
isNsfw: boolean;
|
||||||
|
nsfwEnabled: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,8 @@ function toAuthAccount(session: SessionRecord, activeToken: string | null): Auth
|
|||||||
publicKey: session.user.publicKey,
|
publicKey: session.user.publicKey,
|
||||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
|
isNsfw: session.user.isNsfw,
|
||||||
|
nsfwEnabled: session.user.nsfwEnabled,
|
||||||
isActive: session.token === activeToken,
|
isActive: session.token === activeToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface User {
|
|||||||
did?: string;
|
did?: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
privateKeyEncrypted?: string;
|
privateKeyEncrypted?: string;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nsfwEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthAccount extends User {
|
export interface AuthAccount extends User {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react
|
|||||||
|
|
||||||
interface RuntimeConfig {
|
interface RuntimeConfig {
|
||||||
domain: string;
|
domain: string;
|
||||||
|
isNsfw: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfigContextType {
|
interface ConfigContextType {
|
||||||
@@ -27,12 +28,14 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setConfig({
|
setConfig({
|
||||||
domain: data.domain || 'localhost:43821',
|
domain: data.domain || 'localhost:43821',
|
||||||
|
isNsfw: data.isNsfw === true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Fallback to build-time value if fetch fails
|
// Fallback to build-time value if fetch fails
|
||||||
setConfig({
|
setConfig({
|
||||||
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
|
||||||
|
isNsfw: false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { shouldBlurProfileMedia } from './profile-media';
|
||||||
|
|
||||||
|
describe('shouldBlurProfileMedia', () => {
|
||||||
|
it('blurs sensitive account media for signed-out viewers', () => {
|
||||||
|
expect(shouldBlurProfileMedia({ accountIsNsfw: true, viewer: null })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blurs every account from an NSFW node when the viewer has NSFW disabled', () => {
|
||||||
|
expect(shouldBlurProfileMedia({ nodeIsNsfw: true, viewer: { nsfwEnabled: false } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sensitive profile media when the viewer has enabled NSFW', () => {
|
||||||
|
expect(shouldBlurProfileMedia({ accountIsNsfw: true, nodeIsNsfw: true, viewer: { nsfwEnabled: true } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not blur safe profile media', () => {
|
||||||
|
expect(shouldBlurProfileMedia({ viewer: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface ProfileMediaViewer {
|
||||||
|
nsfwEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldBlurProfileMedia({
|
||||||
|
accountIsNsfw = false,
|
||||||
|
nodeIsNsfw = false,
|
||||||
|
viewer,
|
||||||
|
}: {
|
||||||
|
accountIsNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
|
viewer: ProfileMediaViewer | null;
|
||||||
|
}): boolean {
|
||||||
|
return (accountIsNsfw || nodeIsNsfw) && viewer?.nsfwEnabled !== true;
|
||||||
|
}
|
||||||
@@ -343,6 +343,8 @@ export interface SwarmUserProfile {
|
|||||||
postsCount: number;
|
postsCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw: boolean;
|
||||||
|
nodeIsNsfw: boolean;
|
||||||
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
|
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
|
||||||
nodeDomain: string;
|
nodeDomain: string;
|
||||||
chatPublicKey?: string;
|
chatPublicKey?: string;
|
||||||
@@ -365,6 +367,8 @@ export interface SwarmUserPost {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
nodeDomain?: string;
|
nodeDomain?: string;
|
||||||
};
|
};
|
||||||
media?: { url: string; mimeType?: string; altText?: string }[];
|
media?: { url: string; mimeType?: string; altText?: string }[];
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface User {
|
|||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
profileUrl?: string | null;
|
profileUrl?: string | null;
|
||||||
isBot?: boolean;
|
isBot?: boolean;
|
||||||
|
isNsfw?: boolean;
|
||||||
|
nodeIsNsfw?: boolean;
|
||||||
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
|
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
|
||||||
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
|
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
|
||||||
did?: string;
|
did?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user