Browse Source

Integrate the discussion form

imwald
Silberengel 1 month ago
parent
commit
f7f528baf5
  1. 790
      src/components/PostEditor/PostContent.tsx
  2. 53
      src/components/PostEditor/PostTextarea/index.tsx
  3. 18
      src/components/PostEditor/index.tsx
  4. 4
      src/i18n/locales/de.ts
  5. 4
      src/i18n/locales/en.ts
  6. 249
      src/lib/discussion-thread-composer.ts
  7. 1018
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

790
src/components/PostEditor/PostContent.tsx

File diff suppressed because it is too large Load Diff

53
src/components/PostEditor/PostTextarea/index.tsx

@ -85,29 +85,45 @@ const PostTextarea = forwardRef<
ref ref
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('edit') const [activeTab, setActiveTab] = useState('preview')
const [draftEventJson, setDraftEventJson] = useState<string>('') const [draftEventJson, setDraftEventJson] = useState<string>('')
const [isLoadingJson, setIsLoadingJson] = useState(false) const [isLoadingJson, setIsLoadingJson] = useState(false)
const kindDescription = useMemo(() => getKindDescription(kind), [kind]) const kindDescription = useMemo(() => getKindDescription(kind), [kind])
useEffect(() => { useEffect(() => {
if (activeTab === 'json' && getDraftEventJson) { if (activeTab === 'preview') {
setIsLoadingJson(true)
getDraftEventJson()
.then((json) => {
setDraftEventJson(json)
setIsLoadingJson(false)
})
.catch((error) => {
setDraftEventJson(`Error generating JSON: ${error.message}`)
setIsLoadingJson(false)
})
} else if (activeTab === 'preview') {
// Clear JSON when switching away from JSON tab
setDraftEventJson('') setDraftEventJson('')
setIsLoadingJson(false)
return
} }
}, [activeTab, getDraftEventJson, kind])
if (activeTab !== 'json' || !getDraftEventJson) {
return
}
let cancelled = false
setIsLoadingJson(true)
void Promise.resolve(getDraftEventJson())
.then((json) => {
if (cancelled) return
setDraftEventJson(json)
setIsLoadingJson(false)
})
.catch((error: unknown) => {
if (cancelled) return
const msg = error instanceof Error ? error.message : String(error)
setDraftEventJson(`Error generating JSON: ${msg}`)
setIsLoadingJson(false)
})
return () => {
cancelled = true
}
// `text` is included so JSON refreshes when the parent memoizes `getDraftEventJson` too narrowly;
// `kind` catches compose-mode switches even if callback identity were ever stable across them.
}, [activeTab, getDraftEventJson, kind, text])
const editor = useEditor({ const editor = useEditor({
// TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle. // TipTap + Radix Dialog/Tabs: defer init so React 18 does not warn about flushSync in a lifecycle.
immediatelyRender: false, immediatelyRender: false,
@ -229,7 +245,6 @@ const PostTextarea = forwardRef<
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<TabsList className="w-auto justify-start"> <TabsList className="w-auto justify-start">
<TabsTrigger value="edit">{t('Edit')}</TabsTrigger>
<TabsTrigger value="preview">{t('Preview')}</TabsTrigger> <TabsTrigger value="preview">{t('Preview')}</TabsTrigger>
<TabsTrigger value="json">{t('Json')}</TabsTrigger> <TabsTrigger value="json">{t('Json')}</TabsTrigger>
</TabsList> </TabsList>
@ -239,10 +254,8 @@ const PostTextarea = forwardRef<
</div> </div>
)} )}
</div> </div>
{/* Keep editor mounted: remounting EditorContent after Preview/Json triggers TipTap flushSync under React 18. */} {/* Editor always visible (no Edit tab). Keep mounted; only Preview/Json swap panels below. */}
<TabsContent value="edit" forceMount> <EditorContent className="tiptap" editor={editor} />
<EditorContent className="tiptap" editor={editor} />
</TabsContent>
<TabsContent value="preview"> <TabsContent value="preview">
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">

18
src/components/PostEditor/index.tsx

@ -19,6 +19,7 @@ import { pubkeyToNpub } from '@/lib/pubkey'
import postEditor from '@/services/post-editor.service' import postEditor from '@/services/post-editor.service'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Dispatch, useMemo } from 'react' import { Dispatch, useMemo } from 'react'
import type { TDiscussionDynamicTopics } from '@/lib/discussion-thread-composer'
import PostContent from './PostContent' import PostContent from './PostContent'
export default function PostEditor({ export default function PostEditor({
@ -29,7 +30,8 @@ export default function PostEditor({
openFrom, openFrom,
initialHighlightData, initialHighlightData,
initialPublicMessageTo, initialPublicMessageTo,
onPublishSuccess onPublishSuccess,
discussionDynamicTopics
}: { }: {
defaultContent?: string defaultContent?: string
parentEvent?: Event parentEvent?: Event
@ -41,6 +43,8 @@ export default function PostEditor({
initialPublicMessageTo?: string initialPublicMessageTo?: string
/** Called after a reply/post is successfully published, before closing. */ /** Called after a reply/post is successfully published, before closing. */
onPublishSuccess?: () => void onPublishSuccess?: () => void
/** Hot topics for the discussion (kind 11) composer when integrated in this editor. */
discussionDynamicTopics?: TDiscussionDynamicTopics | null
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
@ -63,9 +67,19 @@ export default function PostEditor({
initialHighlightData={initialHighlightData} initialHighlightData={initialHighlightData}
initialPublicMessageTo={initialPublicMessageTo} initialPublicMessageTo={initialPublicMessageTo}
onPublishSuccess={onPublishSuccess} onPublishSuccess={onPublishSuccess}
discussionDynamicTopics={discussionDynamicTopics}
/> />
) )
}, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData, initialPublicMessageTo, onPublishSuccess]) }, [
effectiveDefaultContent,
parentEvent,
openFrom,
setOpen,
initialHighlightData,
initialPublicMessageTo,
onPublishSuccess,
discussionDynamicTopics
])
if (isSmallScreen) { if (isSmallScreen) {
return ( return (

4
src/i18n/locales/de.ts

@ -1098,6 +1098,8 @@ export default {
'Article exported as AsciiDoc': 'Article exported as AsciiDoc', 'Article exported as AsciiDoc': 'Article exported as AsciiDoc',
'Article exported as Markdown': 'Article exported as Markdown', 'Article exported as Markdown': 'Article exported as Markdown',
'Article title (optional)': 'Article title (optional)', 'Article title (optional)': 'Article title (optional)',
articleDTagDefaultHint:
'Optional. Wenn leer: automatischer d-Tag mit typischem Präfix und Unix-Zeitstempel (Sekunden), z. B. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….',
Audio: 'Audio', Audio: 'Audio',
Author: 'Author', Author: 'Author',
'Author is required for reading groups': 'Author is required for reading groups', 'Author is required for reading groups': 'Author is required for reading groups',
@ -1160,6 +1162,7 @@ export default {
'Create New Thread': 'Create New Thread', 'Create New Thread': 'Create New Thread',
'Create Poll': 'Create Poll', 'Create Poll': 'Create Poll',
'Create Thread': 'Create Thread', 'Create Thread': 'Create Thread',
composeModeKind1: 'Kurznotiz (Kind 1) — andere Beitragsarten abschalten',
'Create a Spell': 'Zauberspruch anlegen', 'Create a Spell': 'Zauberspruch anlegen',
'Creating...': 'Creating...', 'Creating...': 'Creating...',
'D-Tag': 'D-Tag', 'D-Tag': 'D-Tag',
@ -1323,6 +1326,7 @@ export default {
'New Internal Citation': 'New Internal Citation', 'New Internal Citation': 'New Internal Citation',
'New Long-form Article': 'New Long-form Article', 'New Long-form Article': 'New Long-form Article',
'New Poll': 'New Poll', 'New Poll': 'New Poll',
'New Discussion': 'Neue Diskussion',
'New Prompt Citation': 'New Prompt Citation', 'New Prompt Citation': 'New Prompt Citation',
'New Public Message': 'New Public Message', 'New Public Message': 'New Public Message',
'New Wiki Article': 'New Wiki Article', 'New Wiki Article': 'New Wiki Article',

4
src/i18n/locales/en.ts

@ -1133,6 +1133,8 @@ export default {
'Article exported as AsciiDoc': 'Article exported as AsciiDoc', 'Article exported as AsciiDoc': 'Article exported as AsciiDoc',
'Article exported as Markdown': 'Article exported as Markdown', 'Article exported as Markdown': 'Article exported as Markdown',
'Article title (optional)': 'Article title (optional)', 'Article title (optional)': 'Article title (optional)',
articleDTagDefaultHint:
'Optional. If empty, the d-tag defaults to a type-specific prefix plus a Unix timestamp (seconds), e.g. longform-article-…, wiki-article-…, wiki-markdown-…, publication-content-….',
Audio: 'Audio', Audio: 'Audio',
Author: 'Author', Author: 'Author',
'Author is required for reading groups': 'Author is required for reading groups', 'Author is required for reading groups': 'Author is required for reading groups',
@ -1195,6 +1197,7 @@ export default {
'Create New Thread': 'Create New Thread', 'Create New Thread': 'Create New Thread',
'Create Poll': 'Create Poll', 'Create Poll': 'Create Poll',
'Create Thread': 'Create Thread', 'Create Thread': 'Create Thread',
composeModeKind1: 'Short note (kind 1) — turn off other compose types',
'Create a Spell': 'Create a Spell', 'Create a Spell': 'Create a Spell',
'Creating...': 'Creating...', 'Creating...': 'Creating...',
'D-Tag': 'D-Tag', 'D-Tag': 'D-Tag',
@ -1360,6 +1363,7 @@ export default {
'New Internal Citation': 'New Internal Citation', 'New Internal Citation': 'New Internal Citation',
'New Long-form Article': 'New Long-form Article', 'New Long-form Article': 'New Long-form Article',
'New Poll': 'New Poll', 'New Poll': 'New Poll',
'New Discussion': 'New Discussion',
'New Prompt Citation': 'New Prompt Citation', 'New Prompt Citation': 'New Prompt Citation',
'New Public Message': 'New Public Message', 'New Public Message': 'New Public Message',
'New Wiki Article': 'New Wiki Article', 'New Wiki Article': 'New Wiki Article',

249
src/lib/discussion-thread-composer.ts

@ -0,0 +1,249 @@
import { ExtendedKind } from '@/constants'
import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics'
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics'
import { Event } from 'nostr-tools'
import type { LucideIcon } from 'lucide-react'
import { Hash } from 'lucide-react'
/** Isolates TipTap post cache from the main note composer (see postEditorCache.generateCacheKey). */
export const THREAD_POST_EDITOR_PARENT = { id: '__jumble_thread_post_editor__' } as Event
export type TDiscussionDynamicTopics = {
mainTopics: {
id: string
label: string
count: number
isMainTopic: boolean
isSubtopic: boolean
parentTopic?: string
}[]
subtopics: {
id: string
label: string
count: number
isMainTopic: boolean
isSubtopic: boolean
parentTopic?: string
}[]
allTopics: {
id: string
label: string
count: number
isMainTopic: boolean
isSubtopic: boolean
parentTopic?: string
}[]
}
export type TTopicRow = { id: string; label: string; icon: LucideIcon }
type TopicListEntry = { id: string; label: string }
export function extractImagesFromContent(content: string): string[] {
const imageRegex = /(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?)/gi
return content.match(imageRegex) || []
}
export function generateImetaTagsFromUrls(imageUrls: string[]): string[][] {
return imageUrls.map((url) => ['imeta', 'url', url])
}
export function buildDiscussionNsfwTag(): string[] {
return ['content-warning', '']
}
/** Match preset/dynamic list by id or exact label (case-insensitive); otherwise normalize as a new topic slug. */
export function resolveTopicFromInput(raw: string, topics: TopicListEntry[]): string {
const trimmed = raw.trim()
if (!trimmed) return ''
const lower = trimmed.toLowerCase()
const byId = topics.find((x) => x.id === lower)
if (byId) return byId.id
const byLabel = topics.find((x) => x.label.toLowerCase() === lower)
if (byLabel) return byLabel.id
return normalizeTopic(trimmed)
}
export function displayTopicLabel(topicId: string, topics: TopicListEntry[]): string {
const row = topics.find((x) => x.id === topicId)
return row?.label ?? topicId
}
export function buildAllAvailableTopics(dynamicTopics?: TDiscussionDynamicTopics | null): TTopicRow[] {
const combined: TTopicRow[] = [...DISCUSSION_TOPICS]
if (dynamicTopics) {
dynamicTopics.mainTopics.forEach((dynamicTopic) => {
const isGroupsTopic = dynamicTopic.id === 'groups'
combined.push({
id: dynamicTopic.id,
label: `${dynamicTopic.label} (${dynamicTopic.count}) ${isGroupsTopic ? '👥' : '🔥'}`,
icon: Hash
})
})
dynamicTopics.subtopics.forEach((dynamicTopic) => {
const predefinedMainTopic = DISCUSSION_TOPICS.find(
(pt) =>
dynamicTopic.id.toLowerCase().includes(pt.id.toLowerCase()) ||
pt.id.toLowerCase().includes(dynamicTopic.id.toLowerCase())
)
const relatedDynamicMainTopic = dynamicTopics.mainTopics.find(
(dt) =>
dynamicTopic.id.toLowerCase().includes(dt.id.toLowerCase()) ||
dt.id.toLowerCase().includes(dynamicTopic.id.toLowerCase())
)
const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id
if (parentTopic) {
const parentIndex = combined.findIndex((topic) => topic.id === parentTopic)
if (parentIndex !== -1) {
combined.splice(parentIndex + 1, 0, {
id: dynamicTopic.id,
label: ` └─ ${dynamicTopic.label} (${dynamicTopic.count}) 📌`,
icon: Hash
})
} else {
combined.push({
id: dynamicTopic.id,
label: `${dynamicTopic.label} (${dynamicTopic.count}) 📌`,
icon: Hash
})
}
} else {
const generalIndex = combined.findIndex((topic) => topic.id === 'general')
if (generalIndex !== -1) {
combined.splice(generalIndex + 1, 0, {
id: dynamicTopic.id,
label: ` └─ ${dynamicTopic.label} (${dynamicTopic.count}) 📌`,
icon: Hash
})
} else {
combined.push({
id: dynamicTopic.id,
label: `${dynamicTopic.label} (${dynamicTopic.count}) 📌`,
icon: Hash
})
}
}
})
}
return combined
}
export function collectDiscussionThreadTags(params: {
processedContent: string
topicForTags: string
title: string
selectedGroup: string
dynamicTopics?: TDiscussionDynamicTopics | null
isReadingGroup: boolean
author: string
subject: string
isNsfw: boolean
}): string[][] {
const {
processedContent,
topicForTags,
title,
selectedGroup,
dynamicTopics,
isReadingGroup,
author,
subject,
isNsfw
} = params
const images = extractImagesFromContent(processedContent)
const hashtags = extractHashtagsFromContent(processedContent)
const tags: string[][] = [['title', title.trim()], ['-']]
if (topicForTags === 'groups' && selectedGroup) {
tags.push(['h', selectedGroup])
}
if (topicForTags !== 'all' && topicForTags !== 'general' && topicForTags !== 'groups') {
const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags)
if (selectedDynamicTopic?.isSubtopic) {
const predefinedMainTopic = DISCUSSION_TOPICS.find(
(pt) =>
topicForTags.toLowerCase().includes(pt.id.toLowerCase()) ||
pt.id.toLowerCase().includes(topicForTags.toLowerCase())
)
if (predefinedMainTopic) {
tags.push(['t', normalizeTopic(predefinedMainTopic.id)])
tags.push(['t', normalizeTopic(topicForTags)])
} else {
const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find(
(dt) =>
topicForTags.toLowerCase().includes(dt.id.toLowerCase()) ||
dt.id.toLowerCase().includes(topicForTags.toLowerCase())
)
if (relatedDynamicMainTopic) {
tags.push(['t', normalizeTopic(relatedDynamicMainTopic.id)])
tags.push(['t', normalizeTopic(topicForTags)])
} else {
tags.push(['t', normalizeTopic(topicForTags)])
}
}
} else {
tags.push(['t', normalizeTopic(topicForTags)])
}
}
let uniqueHashtags = hashtags
if (topicForTags !== 'all' && topicForTags !== 'general') {
const selectedDynamicTopic = dynamicTopics?.allTopics.find((dt) => dt.id === topicForTags)
if (selectedDynamicTopic?.isSubtopic) {
const predefinedMainTopic = DISCUSSION_TOPICS.find(
(pt) =>
topicForTags.toLowerCase().includes(pt.id.toLowerCase()) ||
pt.id.toLowerCase().includes(topicForTags.toLowerCase())
)
const relatedDynamicMainTopic = dynamicTopics?.mainTopics.find(
(dt) =>
topicForTags.toLowerCase().includes(dt.id.toLowerCase()) ||
dt.id.toLowerCase().includes(topicForTags.toLowerCase())
)
const parentTopic = predefinedMainTopic?.id || relatedDynamicMainTopic?.id
uniqueHashtags = hashtags.filter(
(hashtag) =>
hashtag !== normalizeTopic(topicForTags) && (parentTopic ? hashtag !== normalizeTopic(parentTopic) : true)
)
} else {
uniqueHashtags = hashtags.filter((hashtag) => hashtag !== normalizeTopic(topicForTags))
}
}
for (const hashtag of uniqueHashtags) {
tags.push(['t', hashtag])
}
if (isReadingGroup) {
if (!uniqueHashtags.includes('readings')) {
tags.push(['t', 'readings'])
}
tags.push(['author', author.trim()])
tags.push(['subject', subject.trim()])
}
if (images && images.length > 0) {
tags.push(...generateImetaTagsFromUrls(images))
}
if (isNsfw) {
tags.push(buildDiscussionNsfwTag())
}
return tags
}
export function discussionThreadDraftKindParams() {
return { kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT }
}

1018
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save