Allow captionless media posts while rejecting truly empty posts

Hop-State: A_06FPA2RFGPJ3QTNYV64F90R
Hop-Proposal: R_06FPA2PG7XQ9EJHPV0AMJHG
Hop-Task: T_06FPA1X78BHAFBEY6GBDNT0
Hop-Attempt: AT_06FPA1X78BRF2NXD1SDAYC0
This commit is contained in:
2026-07-15 02:10:51 -07:00
committed by Hop
parent 007ca453d2
commit 6dd3379fb4
5 changed files with 71 additions and 19 deletions
+41 -16
View File
@@ -8,6 +8,7 @@ import { buildNotificationTarget } from '@/lib/notifications';
import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview'; import { serializeLinkPreviewMedia, parseLinkPreviewMediaJson } from '@/lib/media/linkPreview';
import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access'; import { shouldIncludeNsfwFeed } from '@/lib/nsfw/feed-access';
import { isLocalNodeNsfw } from '@/lib/node/local-node'; import { isLocalNodeNsfw } from '@/lib/node/local-node';
import { hasPublishablePostContent } from '@/lib/posts/content-policy';
const POST_MAX_LENGTH = 600; const POST_MAX_LENGTH = 600;
const CURATION_WINDOW_HOURS = 72; const CURATION_WINDOW_HOURS = 72;
@@ -147,7 +148,7 @@ const feedPostRelations = {
} as const; } as const;
const createPostSchema = z.object({ 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) replyToId: z.string().uuid().optional(), // Must be UUID (swarm replies use separate field)
swarmReplyTo: z.object({ swarmReplyTo: z.object({
postId: z.string(), postId: z.string(),
@@ -176,6 +177,14 @@ const createPostSchema = z.object({
mimeType: z.string().optional().nullable(), mimeType: z.string().optional().nullable(),
})).optional().nullable(), })).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 { function isSignedActionPayload(payload: unknown): payload is SignedAction {
@@ -220,9 +229,32 @@ export async function POST(request: Request) {
}) : null, }) : 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({ const [post] = await db.insert(posts).values({
userId: user.id, userId: user.id,
content: data.content, content: postContent,
replyToId: data.replyToId, replyToId: data.replyToId,
...swarmReplyFields, ...swarmReplyFields,
isNsfw: data.isNsfw || user.isNsfw || false, // Inherit from account if account is NSFW 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), linkPreviewMediaJson: serializeLinkPreviewMedia(data.linkPreview?.media),
}).returning(); }).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 { try {
if (data.swarmReplyTo) { if (data.swarmReplyTo) {
const protocol = data.swarmReplyTo.nodeDomain.includes('localhost') ? 'http' : 'https'; 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) await db.update(media)
.set({ postId: post.id }) .set({ postId: post.id })
.where(and( .where(and(
inArray(media.id, data.mediaIds), inArray(media.id, requestedMediaIds),
eq(media.userId, user.id), eq(media.userId, user.id),
isNull(media.postId), isNull(media.postId),
)); ));
@@ -318,9 +343,9 @@ export async function POST(request: Request) {
} }
let attachedMedia: typeof media.$inferSelect[] = []; let attachedMedia: typeof media.$inferSelect[] = [];
if (data.mediaIds?.length) { if (requestedMediaIds.length > 0) {
attachedMedia = await db.query.media.findMany({ 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 () => { (async () => {
try { try {
const { extractMentions } = await import('@/lib/swarm/interactions'); const { extractMentions } = await import('@/lib/swarm/interactions');
const mentions = extractMentions(data.content); const mentions = extractMentions(postContent);
for (const mention of mentions) { for (const mention of mentions) {
// Only handle local mentions (no domain) // Only handle local mentions (no domain)
@@ -420,7 +445,7 @@ export async function POST(request: Request) {
} catch (err) { } catch (err) {
// Log error with context but don't fail the request - mention notifications are best-effort // 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] 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 { deliverSwarmMentions } = await import('@/lib/swarm/interactions');
const result = await deliverSwarmMentions( const result = await deliverSwarmMentions(
data.content, postContent,
post.id, post.id,
{ {
handle: user.handle, handle: user.handle,
+3 -2
View File
@@ -47,6 +47,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
const mediaInputRef = useRef<HTMLInputElement>(null); const mediaInputRef = useRef<HTMLInputElement>(null);
const maxLength = 600; const maxLength = 600;
const remaining = maxLength - content.length; const remaining = maxLength - content.length;
const canSubmit = content.trim().length > 0 || attachments.length > 0;
const previewMedia = linkPreview?.media?.length const previewMedia = linkPreview?.media?.length
? linkPreview.media ? linkPreview.media
: linkPreview?.image : linkPreview?.image
@@ -109,7 +110,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
}; };
const handleSubmit = async () => { 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 // With persistence, identity should be unlocked. If not, user needs to re-login
if (!isIdentityUnlocked) { if (!isIdentityUnlocked) {
@@ -364,7 +365,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!content.trim() || remaining < 0 || isPosting || isUploading} disabled={!canSubmit || remaining < 0 || isPosting || isUploading}
> >
{isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'} {isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'}
</button> </button>
+2
View File
@@ -652,7 +652,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<span className="post-time">{authorHandle}</span> <span className="post-time">{authorHandle}</span>
</div> </div>
</div> </div>
{post.content.trim() && (
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div> <div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
)}
</article> </article>
); );
} }
+18
View File
@@ -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);
});
});
+6
View File
@@ -0,0 +1,6 @@
export function hasPublishablePostContent(
content: string,
mediaIds: readonly string[] | undefined,
): boolean {
return content.trim().length > 0 || (mediaIds?.length ?? 0) > 0;
}