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)
+}