diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 995ee06..9cbdab0 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -8,6 +8,7 @@ import { buildNotificationTarget } from '@/lib/notifications'; import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access'; import { isLocalNodeNsfw } from '@/lib/node/local-node'; +import { hasPublishablePostContent } from '@/lib/posts/content-policy'; const POST_MAX_LENGTH = 600; const CURATION_WINDOW_HOURS = 72; @@ -147,7 +148,7 @@ const feedPostRelations = { } as const; const createPostSchema = z.object({ - content: z.string().min(1).max(POST_MAX_LENGTH), + content: z.string().max(POST_MAX_LENGTH), replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field) swarmReplyTo: z.object({ postId: z.string(), @@ -176,6 +177,14 @@ const createPostSchema = z.object({ mimeType: z.string().optional().nullable(), })).optional().nullable(), }).optional().nullable(), +}).superRefine((post, context) => { + if (!hasPublishablePostContent(post.content, post.mediaIds)) { + context.addIssue({ + code: 'custom', + path: ['content'], + message: 'Add text or attach media before posting', + }); + } }); function isSignedActionPayload(payload: unknown): payload is SignedAction { @@ -220,9 +229,32 @@ export async function POST(request: Request) { }) : null, } : {}; + const requestedMediaIds = [...new Set(data.mediaIds || [])]; + let unattachedMedia: typeof media.$inferSelect[] = []; + if (requestedMediaIds.length > 0) { + unattachedMedia = await db.query.media.findMany({ + where: { AND: [{ id: { in: requestedMediaIds } }, { userId: user.id }, { postId: { isNull: true } }] }, + }); + } + + if (unattachedMedia.length !== requestedMediaIds.length) { + return NextResponse.json( + { error: 'One or more media attachments are unavailable. Please upload them again.' }, + { status: 400 }, + ); + } + + const postContent = data.content.trim(); + if (!hasPublishablePostContent(postContent, unattachedMedia.map(item => item.id))) { + return NextResponse.json( + { error: 'Add text or attach media before posting.' }, + { status: 400 }, + ); + } + const [post] = await db.insert(posts).values({ userId: user.id, - content: data.content, + content: postContent, replyToId: data.replyToId, ...swarmReplyFields, isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW @@ -237,13 +269,6 @@ export async function POST(request: Request) { linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media), }).returning(); - let unattachedMedia: typeof media.$inferSelect[] = []; - if (data.mediaIds?.length) { - unattachedMedia = await db.query.media.findMany({ - where: { AND: [{ id: { in: data.mediaIds } }, { userId: user.id }, { postId: { isNull: true } }] }, - }); - } - try { if (data.swarmReplyTo) { const protocol = data.swarmReplyTo.nodeDomain.includes('localhost') ? 'http' : 'https'; @@ -287,11 +312,11 @@ export async function POST(request: Request) { } } - if (data.mediaIds?.length) { + if (requestedMediaIds.length > 0) { await db.update(media) .set({ postId: post.id }) .where(and( - inArray(media.id, data.mediaIds), + inArray(media.id, requestedMediaIds), eq(media.userId, user.id), isNull(media.postId), )); @@ -318,9 +343,9 @@ export async function POST(request: Request) { } let attachedMedia: typeof media.$inferSelect[] = []; - if (data.mediaIds?.length) { + if (requestedMediaIds.length > 0) { attachedMedia = await db.query.media.findMany({ - where: { AND: [{ id: { in: data.mediaIds } }, { userId: user.id }, { postId: post.id }] }, + where: { AND: [{ id: { in: requestedMediaIds } }, { userId: user.id }, { postId: post.id }] }, }); } @@ -374,7 +399,7 @@ export async function POST(request: Request) { (async () => { try { const { extractMentions } = await import('@/lib/swarm/interactions'); - const mentions = extractMentions(data.content); + const mentions = extractMentions(postContent); for (const mention of mentions) { // Only handle local mentions (no domain) @@ -420,7 +445,7 @@ export async function POST(request: Request) { } catch (err) { // Log error with context but don't fail the request - mention notifications are best-effort console.error('[Posts] Error creating mention notifications:', err); - console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: data.content?.slice(0, 100) }); + console.error('[Posts] Context:', { postId: post.id, userId: user.id, content: postContent.slice(0, 100) }); } })(); @@ -430,7 +455,7 @@ export async function POST(request: Request) { const { deliverSwarmMentions } = await import('@/lib/swarm/interactions'); const result = await deliverSwarmMentions( - data.content, + postContent, post.id, { handle: user.handle, diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index bc3f8e7..9e15cde 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -47,6 +47,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold const mediaInputRef = useRef(null); const maxLength = 600; const remaining = maxLength - content.length; + const canSubmit = content.trim().length > 0 || attachments.length > 0; const previewMedia = linkPreview?.media?.length ? linkPreview.media : linkPreview?.image @@ -109,7 +110,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold }; const handleSubmit = async () => { - if (!content.trim() || isPosting || isUploading) return; + if (!canSubmit || isPosting || isUploading) return; // With persistence, identity should be unlocked. If not, user needs to re-login if (!isIdentityUnlocked) { @@ -364,7 +365,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 672fd00..9672805 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -652,7 +652,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, {authorHandle} -
{renderContent(post.content, post.linkPreviewUrl ?? undefined)}
+ {post.content.trim() && ( +
{renderContent(post.content, post.linkPreviewUrl ?? undefined)}
+ )} ); } diff --git a/src/lib/posts/content-policy.test.ts b/src/lib/posts/content-policy.test.ts new file mode 100644 index 0000000..23a6c43 --- /dev/null +++ b/src/lib/posts/content-policy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { hasPublishablePostContent } from './content-policy'; + +describe('hasPublishablePostContent', () => { + it('accepts a text-only post', () => { + expect(hasPublishablePostContent('Hello swarm', [])).toBe(true); + }); + + it('accepts attached media without a caption', () => { + expect(hasPublishablePostContent('', ['media-id'])).toBe(true); + expect(hasPublishablePostContent(' ', ['media-id'])).toBe(true); + }); + + it('rejects a completely empty post', () => { + expect(hasPublishablePostContent('', [])).toBe(false); + expect(hasPublishablePostContent(' ', undefined)).toBe(false); + }); +}); diff --git a/src/lib/posts/content-policy.ts b/src/lib/posts/content-policy.ts new file mode 100644 index 0000000..5df71c5 --- /dev/null +++ b/src/lib/posts/content-policy.ts @@ -0,0 +1,6 @@ +export function hasPublishablePostContent( + content: string, + mediaIds: readonly string[] | undefined, +): boolean { + return content.trim().length > 0 || (mediaIds?.length ?? 0) > 0; +}