From 84ebb7bdb1ed3a7dee3a01deebf9398f6aac87c2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 22 May 2026 14:11:54 +0200 Subject: [PATCH] prevent empty publishing of notes and replies --- .../NoteOptions/EditOrCloneEventDialog.tsx | 13 +- src/components/PostEditor/PostContent.tsx | 134 +++++++++--------- src/lib/publish-content-required.test.ts | 30 ++++ src/lib/publish-content-required.ts | 27 ++++ 4 files changed, 139 insertions(+), 65 deletions(-) create mode 100644 src/lib/publish-content-required.test.ts create mode 100644 src/lib/publish-content-required.ts diff --git a/src/components/NoteOptions/EditOrCloneEventDialog.tsx b/src/components/NoteOptions/EditOrCloneEventDialog.tsx index ee019a56..e5bca47d 100644 --- a/src/components/NoteOptions/EditOrCloneEventDialog.tsx +++ b/src/components/NoteOptions/EditOrCloneEventDialog.tsx @@ -50,6 +50,7 @@ import { useTranslation } from 'react-i18next' import AdvancedEventLabDialog from '@/components/AdvancedEventLab/AdvancedEventLabDialog' import type { AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' import { isAsciidocMarkupKind } from '@/lib/advanced-event-lab-kinds' +import { canPublishWithContent } from '@/lib/publish-content-required' function normalizeTagRow(row: string[]): string[] | null { const trimmed = row.map((c) => c.trim()) @@ -163,6 +164,11 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp const kind = isCreate ? (parsedCreateKind ?? 0) : sourceEvent!.kind + const canPublishEvent = useMemo( + () => canPublishWithContent(kind, content), + [kind, content] + ) + useEffect(() => { if (open && !prevOpenRef.current) { if (isCreate) { @@ -286,6 +292,9 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp const handlePublish = async () => { await checkLogin(async () => { if (!pubkey) return + if (!canPublishWithContent(isCreate ? parseEventKindInput(createKindInput) ?? 0 : sourceEvent!.kind, content)) { + return + } if (isCreate) { const k = parseEventKindInput(createKindInput) if (k === null) { @@ -616,7 +625,9 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 3ecac1bd..a5eaf70c 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -79,6 +79,7 @@ import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' import mediaUpload from '@/services/media-upload.service' import type { TPrePublishRelayCapPreview } from '@/lib/pre-publish-relay-cap' +import { canPublishWithContent, publishRequiresNonemptyContent } from '@/lib/publish-content-required' import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publish-relay-urls' import client, { eventService } from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' @@ -479,70 +480,6 @@ export default function PostContent({ if (isPoll) setRelayCapBlockInfo(null) }, [isPoll]) - const canPost = useMemo(() => { - const discussionOk = - !isDiscussionThread || - !!parentEvent || - (!!threadTitle.trim() && - threadTitle.length <= 100 && - !!threadTopicResolved && - !!text.trim() && - text.length <= 5000 && - additionalRelayUrls.length > 0 && - (!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim()))) - const result = ( - !!pubkey && - !posting && - !uploadProgresses.length && - discussionOk && - // For media notes, text is optional - just need media - ((mediaNoteKind !== null && mediaUrl) || !!text) && - (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && - (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && - (!isProtectedEvent || additionalRelayUrls.length > 0) && - (!isHighlight || highlightData.sourceValue.trim() !== '') && - // For citations, required fields must be filled - (!isCitationInternal || !!citationInternalCTag.trim()) && - (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && - (!isCitationHardcopy || !!citationAccessedOn.trim()) && - (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) && - relayCapBlockInfo === null - ) - - return result - }, [ - pubkey, - text, - posting, - uploadProgresses, - mediaNoteKind, - mediaUrl, - isPoll, - pollCreateData, - isPublicMessage, - extractedMentions, - parentEvent, - isProtectedEvent, - additionalRelayUrls, - isHighlight, - highlightData, - isCitationInternal, - citationInternalCTag, - isCitationExternal, - citationExternalUrl, - citationAccessedOn, - isCitationHardcopy, - isCitationPrompt, - citationPromptLlm, - isDiscussionThread, - threadTitle, - threadTopicResolved, - threadIsReadingGroup, - threadReadingAuthor, - threadReadingSubject, - relayCapBlockInfo - ]) - // Clear highlight data when initialHighlightData changes or is removed useEffect(() => { if (initialHighlightData) { @@ -683,6 +620,72 @@ export default function PostContent({ parentEvent ]) + const canPost = useMemo(() => { + const discussionOk = + !isDiscussionThread || + !!parentEvent || + (!!threadTitle.trim() && + threadTitle.length <= 100 && + !!threadTopicResolved && + !!text.trim() && + text.length <= 5000 && + additionalRelayUrls.length > 0 && + (!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim()))) + const requiresNonemptyContent = publishRequiresNonemptyContent(getDeterminedKind) + const hasNonemptyContent = text.trim().length > 0 + const contentOk = requiresNonemptyContent + ? hasNonemptyContent + : (mediaNoteKind !== null && mediaUrl) || hasNonemptyContent + return ( + !!pubkey && + !posting && + !uploadProgresses.length && + discussionOk && + contentOk && + (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && + (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && + (!isProtectedEvent || additionalRelayUrls.length > 0) && + (!isHighlight || highlightData.sourceValue.trim() !== '') && + (!isCitationInternal || !!citationInternalCTag.trim()) && + (!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && + (!isCitationHardcopy || !!citationAccessedOn.trim()) && + (!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) && + relayCapBlockInfo === null + ) + }, [ + pubkey, + text, + getDeterminedKind, + posting, + uploadProgresses, + mediaNoteKind, + mediaUrl, + isPoll, + pollCreateData, + isPublicMessage, + extractedMentions, + parentEvent, + isProtectedEvent, + additionalRelayUrls, + isHighlight, + highlightData, + isCitationInternal, + citationInternalCTag, + isCitationExternal, + citationExternalUrl, + citationAccessedOn, + isCitationHardcopy, + isCitationPrompt, + citationPromptLlm, + isDiscussionThread, + threadTitle, + threadTopicResolved, + threadIsReadingGroup, + threadReadingAuthor, + threadReadingSubject, + relayCapBlockInfo + ]) + const getDeterminedKindRef = useRef(getDeterminedKind) getDeterminedKindRef.current = getDeterminedKind @@ -1282,6 +1285,9 @@ export default function PostContent({ logger.warn('Attempted to post while canPost is false') return } + if (!canPublishWithContent(getDeterminedKind, text)) { + return + } if (isDiscussionThread && !parentEvent) { const newErrors: typeof threadErrors = {} diff --git a/src/lib/publish-content-required.test.ts b/src/lib/publish-content-required.test.ts new file mode 100644 index 00000000..83fd29c5 --- /dev/null +++ b/src/lib/publish-content-required.test.ts @@ -0,0 +1,30 @@ +import { ExtendedKind } from '@/constants' +import { + canPublishWithContent, + publishRequiresNonemptyContent, + PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS +} from '@/lib/publish-content-required' +import { kinds } from 'nostr-tools' +import { describe, expect, it } from 'vitest' + +describe('publish-content-required', () => { + it('includes the listed text-bearing kinds', () => { + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(kinds.ShortTextNote)).toBe(true) + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(ExtendedKind.COMMENT)).toBe(true) + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(ExtendedKind.PUBLIC_MESSAGE)).toBe(true) + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(kinds.LongFormArticle)).toBe(true) + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(ExtendedKind.NOSTR_SPECIFICATION)).toBe(true) + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(ExtendedKind.WIKI_ARTICLE)).toBe(true) + expect(PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(ExtendedKind.PUBLICATION_CONTENT)).toBe(true) + }) + + it('rejects whitespace-only content for kind 1', () => { + expect(publishRequiresNonemptyContent(1)).toBe(true) + expect(canPublishWithContent(1, ' ')).toBe(false) + expect(canPublishWithContent(1, 'hello')).toBe(true) + }) + + it('allows empty content for other kinds', () => { + expect(canPublishWithContent(ExtendedKind.POLL, '')).toBe(true) + }) +}) diff --git a/src/lib/publish-content-required.ts b/src/lib/publish-content-required.ts new file mode 100644 index 00000000..6a346299 --- /dev/null +++ b/src/lib/publish-content-required.ts @@ -0,0 +1,27 @@ +import { ExtendedKind } from '@/constants' +import { kinds } from 'nostr-tools' + +/** Kinds that must have non-whitespace `content` before Publish is enabled. */ +export const PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS: ReadonlySet = new Set([ + kinds.ShortTextNote, // 1 — notes and kind-1 replies + ExtendedKind.COMMENT, // 1111 + ExtendedKind.PUBLIC_MESSAGE, // 24 + kinds.LongFormArticle, // 30023 + ExtendedKind.NOSTR_SPECIFICATION, // 30817 + ExtendedKind.WIKI_ARTICLE, // 30818 + ExtendedKind.PUBLICATION_CONTENT // 30041 +]) + +export function publishRequiresNonemptyContent(kind: number): boolean { + return PUBLISH_REQUIRES_NONEMPTY_CONTENT_KINDS.has(kind) +} + +export function hasNonemptyPublishContent(content: string): boolean { + return content.trim().length > 0 +} + +/** Whether Publish should be enabled for this kind and composer body. */ +export function canPublishWithContent(kind: number, content: string): boolean { + if (!publishRequiresNonemptyContent(kind)) return true + return hasNonemptyPublishContent(content) +}