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:
+41
-16
@@ -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,
|
||||
|
||||
@@ -47,6 +47,7 @@ export function Compose({ onPost, onPosted, replyingTo, onCancelReply, placehold
|
||||
const mediaInputRef = useRef<HTMLInputElement>(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
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || remaining < 0 || isPosting || isUploading}
|
||||
disabled={!canSubmit || remaining < 0 || isPosting || isUploading}
|
||||
>
|
||||
{isPosting ? 'Posting...' : isReply ? 'Reply' : 'Post'}
|
||||
</button>
|
||||
|
||||
@@ -652,7 +652,9 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
<span className="post-time">{authorHandle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
|
||||
{post.content.trim() && (
|
||||
<div className="post-content">{renderContent(post.content, post.linkPreviewUrl ?? undefined)}</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function hasPublishablePostContent(
|
||||
content: string,
|
||||
mediaIds: readonly string[] | undefined,
|
||||
): boolean {
|
||||
return content.trim().length > 0 || (mediaIds?.length ?? 0) > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user