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 { 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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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