Browse Source

prevent empty publishing of notes and replies

imwald
Silberengel 3 weeks ago
parent
commit
84ebb7bdb1
  1. 13
      src/components/NoteOptions/EditOrCloneEventDialog.tsx
  2. 134
      src/components/PostEditor/PostContent.tsx
  3. 30
      src/lib/publish-content-required.test.ts
  4. 27
      src/lib/publish-content-required.ts

13
src/components/NoteOptions/EditOrCloneEventDialog.tsx

@ -50,6 +50,7 @@ import { useTranslation } from 'react-i18next' @@ -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 @@ -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 @@ -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 @@ -616,7 +625,9 @@ export default function EditOrCloneEventDialog(props: EditOrCloneEventDialogProp
<Button
type="button"
onClick={handlePublish}
disabled={publishing || !pubkey || (isCreate && parsedCreateKind === null)}
disabled={
publishing || !pubkey || (isCreate && parsedCreateKind === null) || !canPublishEvent
}
>
{publishing ? t('Loading...') : t('Publish')}
</Button>

134
src/components/PostEditor/PostContent.tsx

@ -79,6 +79,7 @@ import { getMediaKindFromFile } from '@/lib/media-kind-detection' @@ -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({ @@ -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({ @@ -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({ @@ -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 = {}

30
src/lib/publish-content-required.test.ts

@ -0,0 +1,30 @@ @@ -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)
})
})

27
src/lib/publish-content-required.ts

@ -0,0 +1,27 @@ @@ -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<number> = 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)
}
Loading…
Cancel
Save