Browse Source

bug-fixes

added missing fields to articles
imwald
Silberengel 4 months ago
parent
commit
37ad1b9184
  1. 2
      src/components/Note/Highlight/index.tsx
  2. 630
      src/components/PostEditor/PostContent.tsx
  3. 89
      src/components/PostEditor/PostTextarea/Preview.tsx
  4. 12
      src/components/PostEditor/PostTextarea/index.tsx
  5. 1
      src/constants.ts
  6. 12
      src/index.css
  7. 34
      src/lib/draft-event.ts
  8. 2
      src/lib/kind-description.ts
  9. 92
      src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx
  10. 2
      src/pages/secondary/PostSettingsPage/index.tsx

2
src/components/Note/Highlight/index.tsx

@ -307,7 +307,7 @@ export default function Highlight({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Full quoted text with highlighted portion */} {/* Full quoted text with highlighted portion */}
{context && ( {context && (
<div className="text-base font-normal mb-4 whitespace-pre-wrap break-words border-l-4 border-green-500 pl-5 py-4 leading-relaxed bg-green-50/30 dark:bg-green-950/20 rounded-r-lg"> <div className="note-content text-base font-normal mb-4 whitespace-pre-wrap break-words border-l-4 border-green-500 pl-5 py-4 leading-relaxed bg-green-50/30 dark:bg-green-950/20 rounded-r-lg">
{contextTag && highlightedText ? ( {contextTag && highlightedText ? (
// If we have both context and highlighted text, show the highlight within the context // If we have both context and highlighted text, show the highlight within the context
<div> <div>

630
src/components/PostEditor/PostContent.tsx

@ -1,6 +1,9 @@
import Note from '@/components/Note' import Note from '@/components/Note'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -40,9 +43,9 @@ import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video } from 'lucide-react' import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload, Mic, Music, Video } from 'lucide-react'
import { getMediaKindFromFile } from '@/lib/media-kind-detection' import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls, hasCacheRelays, getCacheRelayUrls } from '@/lib/private-relays' import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { StorageKey } from '@/constants' import client from '@/services/client.service'
import { isProtectedEvent as isEventProtected, isReplyNoteEvent } from '@/lib/event' import { isProtectedEvent as isEventProtected, isReplyNoteEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -111,18 +114,22 @@ export default function PostContent({
const [isWikiArticle, setIsWikiArticle] = useState(false) const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false) const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false) const [isPublicationContent, setIsPublicationContent] = useState(false)
const [articleTitle, setArticleTitle] = useState('')
const [articleDTag, setArticleDTag] = useState('')
const [articleImage, setArticleImage] = useState('')
const [articleSubject, setArticleSubject] = useState('')
const [articleSummary, setArticleSummary] = useState('')
const [isCitationInternal, setIsCitationInternal] = useState(false) const [isCitationInternal, setIsCitationInternal] = useState(false)
const [isCitationExternal, setIsCitationExternal] = useState(false) const [isCitationExternal, setIsCitationExternal] = useState(false)
const [isCitationHardcopy, setIsCitationHardcopy] = useState(false) const [isCitationHardcopy, setIsCitationHardcopy] = useState(false)
const [isCitationPrompt, setIsCitationPrompt] = useState(false) const [isCitationPrompt, setIsCitationPrompt] = useState(false)
const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false) const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false)
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false)
const [useCacheOnlyForPrivateNotes, setUseCacheOnlyForPrivateNotes] = useState(true) // Default ON
const [showMediaKindDialog, setShowMediaKindDialog] = useState(false) const [showMediaKindDialog, setShowMediaKindDialog] = useState(false)
const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string; tags: string[][]; file: File } | null>(null) const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string; tags: string[][]; file: File } | null>(null)
const uploadedMediaFileMap = useRef<Map<string, File>>(new Map()) const uploadedMediaFileMap = useRef<Map<string, File>>(new Map())
const isFirstRender = useRef(true) const isFirstRender = useRef(true)
const canPost = useMemo(() => { const canPost = useMemo(() => {
const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent
const result = ( const result = (
!!pubkey && !!pubkey &&
!posting && !posting &&
@ -132,7 +139,9 @@ export default function PostContent({
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
(!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && (!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) &&
(!isProtectedEvent || additionalRelayUrls.length > 0) && (!isProtectedEvent || additionalRelayUrls.length > 0) &&
(!isHighlight || highlightData.sourceValue.trim() !== '') (!isHighlight || highlightData.sourceValue.trim() !== '') &&
// For articles, dTag is mandatory
(!isArticle || !!articleDTag.trim())
) )
return result return result
@ -151,7 +160,12 @@ export default function PostContent({
isProtectedEvent, isProtectedEvent,
additionalRelayUrls, additionalRelayUrls,
isHighlight, isHighlight,
highlightData highlightData,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isPublicationContent,
articleDTag
]) ])
// Clear highlight data when initialHighlightData changes or is removed // Clear highlight data when initialHighlightData changes or is removed
@ -238,41 +252,12 @@ export default function PostContent({
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
setHasPrivateRelaysAvailable(false) setHasPrivateRelaysAvailable(false)
setHasCacheRelaysAvailable(false)
return return
} }
hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => { hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => {
setHasPrivateRelaysAvailable(false) setHasPrivateRelaysAvailable(false)
}) })
hasCacheRelays(pubkey).then(setHasCacheRelaysAvailable).catch(() => {
setHasCacheRelaysAvailable(false)
})
}, [pubkey])
// Load cache-only preference from localStorage
// Default depends on whether cache relays exist
useEffect(() => {
const updateCachePreference = async () => {
if (!pubkey) {
setUseCacheOnlyForPrivateNotes(false)
return
}
const hasCache = await hasCacheRelays(pubkey).catch(() => false)
if (hasCache) {
// If cache exists, load from localStorage or default to true (ON)
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
setUseCacheOnlyForPrivateNotes(stored === null ? true : stored === 'true')
} else {
// If no cache, default to false (OFF) - use only outboxes
setUseCacheOnlyForPrivateNotes(false)
}
}
updateCachePreference()
}, [pubkey]) }, [pubkey])
// Helper function to determine the kind that will be created // Helper function to determine the kind that will be created
@ -342,27 +327,9 @@ export default function PostContent({
parentEvent parentEvent
]) ])
// Function to generate draft event JSON for preview // Shared function to create draft event - used by both preview and posting
const getDraftEventJson = useCallback(async (): Promise<string> => { const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => {
if (!pubkey) {
return JSON.stringify({ error: 'Not logged in' }, null, 2)
}
try {
// Clean tracking parameters from URLs in the post content
const cleanedText = text.replace(
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
// Get expiration and quiet settings // Get expiration and quiet settings
// Only add expiration tags to chatting kinds: 1, 1111, 1222, 1244
const isChattingKind = (kind: number) => const isChattingKind = (kind: number) =>
kind === kinds.ShortTextNote || kind === kinds.ShortTextNote ||
kind === ExtendedKind.COMMENT || kind === ExtendedKind.COMMENT ||
@ -375,31 +342,18 @@ export default function PostContent({
const quietDays = storage.getDefaultQuietDays() const quietDays = storage.getDefaultQuietDays()
// Determine if we should use protected event tag // Determine if we should use protected event tag
// Only use it when replying to an OP event that also has the "-" tag
let shouldUseProtectedEvent = false let shouldUseProtectedEvent = false
if (parentEvent) { if (parentEvent) {
// Check if parent event is an OP (not a reply itself) and has the "-" tag
const isParentOP = !isReplyNoteEvent(parentEvent) const isParentOP = !isReplyNoteEvent(parentEvent)
const parentHasProtectedTag = isEventProtected(parentEvent) const parentHasProtectedTag = isEventProtected(parentEvent)
shouldUseProtectedEvent = isParentOP && parentHasProtectedTag shouldUseProtectedEvent = isParentOP && parentHasProtectedTag
} }
let draftEvent: any = null // Check for voice comments first
// Check for voice comments first - even if mediaUrl is not set yet (for preview purposes)
console.log('🔍 getDraftEventJson: checking voice comment', {
parentEvent: !!parentEvent,
mediaNoteKind,
VOICE_COMMENT: ExtendedKind.VOICE_COMMENT,
match: parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT,
typeof_mediaNoteKind: typeof mediaNoteKind
})
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
// Voice comment - use placeholder URL if mediaUrl not set yet
console.log('✅ getDraftEventJson: creating voice comment draft event')
const url = mediaUrl || 'placeholder://audio' const url = mediaUrl || 'placeholder://audio'
const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']] const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']]
draftEvent = await createVoiceCommentDraftEvent( return await createVoiceCommentDraftEvent(
cleanedText, cleanedText,
parentEvent, parentEvent,
url, url,
@ -415,11 +369,12 @@ export default function PostContent({
quietDays quietDays
} }
) )
} else if (mediaNoteKind !== null && mediaUrl) { }
// Media notes // Media notes
if (mediaNoteKind !== null && mediaUrl) {
if (mediaNoteKind === ExtendedKind.VOICE) { if (mediaNoteKind === ExtendedKind.VOICE) {
// Voice note return await createVoiceDraftEvent(
draftEvent = await createVoiceDraftEvent(
cleanedText, cleanedText,
mediaUrl, mediaUrl,
mediaImetaTags, mediaImetaTags,
@ -434,23 +389,21 @@ export default function PostContent({
} }
) )
} else if (mediaNoteKind === ExtendedKind.PICTURE) { } else if (mediaNoteKind === ExtendedKind.PICTURE) {
// Picture note return await createPictureDraftEvent(
draftEvent = await createPictureDraftEvent(
cleanedText, cleanedText,
mediaImetaTags, mediaImetaTags,
mentions, mentions,
{ {
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Picture notes are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
} }
) )
} else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) { } else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) {
// Video note return await createVideoDraftEvent(
draftEvent = await createVideoDraftEvent(
cleanedText, cleanedText,
mediaImetaTags, mediaImetaTags,
mentions, mentions,
@ -458,112 +411,146 @@ export default function PostContent({
{ {
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Video notes are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
} }
) )
} }
} else if (isLongFormArticle) { }
draftEvent = await createLongFormArticleDraftEvent(cleanedText, mentions, {
// Parse topics from subject field for articles
const topics = articleSubject.trim()
? articleSubject.split(/[,\s]+/).filter(s => s.trim())
: []
// Articles
if (isLongFormArticle) {
return await createLongFormArticleDraftEvent(cleanedText, mentions, {
dTag: articleDTag.trim(),
title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined,
image: articleImage.trim() || undefined,
topics: topics.length > 0 ? topics : undefined,
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Articles are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (isWikiArticle) { } else if (isWikiArticle) {
draftEvent = await createWikiArticleDraftEvent(cleanedText, mentions, { return await createWikiArticleDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content dTag: articleDTag.trim(),
title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined,
image: articleImage.trim() || undefined,
topics: topics.length > 0 ? topics : undefined,
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (isWikiArticleMarkdown) { } else if (isWikiArticleMarkdown) {
draftEvent = await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, { return await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content dTag: articleDTag.trim(),
title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined,
image: articleImage.trim() || undefined,
topics: topics.length > 0 ? topics : undefined,
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (isPublicationContent) { } else if (isPublicationContent) {
draftEvent = await createPublicationContentDraftEvent(cleanedText, mentions, { return await createPublicationContentDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content dTag: articleDTag.trim(),
title: articleTitle.trim() || undefined,
summary: articleSummary.trim() || undefined,
image: articleImage.trim() || undefined,
topics: topics.length > 0 ? topics : undefined,
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Publication content is not a chatting kind addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (isCitationInternal) { }
// For now, use a simple format - in a real implementation, this would have a form
draftEvent = createCitationInternalDraftEvent(cleanedText, { // Citations
cTag: '', // Would need to be filled from a form if (isCitationInternal) {
return createCitationInternalDraftEvent(cleanedText, {
cTag: '',
title: cleanedText.substring(0, 100) title: cleanedText.substring(0, 100)
}) })
} else if (isCitationExternal) { } else if (isCitationExternal) {
draftEvent = createCitationExternalDraftEvent(cleanedText, { return createCitationExternalDraftEvent(cleanedText, {
url: '', // Would need to be filled from a form url: '',
accessedOn: new Date().toISOString(), accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100) title: cleanedText.substring(0, 100)
}) })
} else if (isCitationHardcopy) { } else if (isCitationHardcopy) {
draftEvent = createCitationHardcopyDraftEvent(cleanedText, { return createCitationHardcopyDraftEvent(cleanedText, {
accessedOn: new Date().toISOString(), accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100) title: cleanedText.substring(0, 100)
}) })
} else if (isCitationPrompt) { } else if (isCitationPrompt) {
draftEvent = createCitationPromptDraftEvent(cleanedText, { return createCitationPromptDraftEvent(cleanedText, {
llm: '', // Would need to be filled from a form llm: '',
accessedOn: new Date().toISOString() accessedOn: new Date().toISOString()
}) })
} else if (isHighlight) { }
// For highlights, pass the original sourceValue which contains the full identifier
// The createHighlightDraftEvent function will parse it correctly // Highlights
draftEvent = await createHighlightDraftEvent( if (isHighlight) {
return await createHighlightDraftEvent(
cleanedText, cleanedText,
highlightData.sourceType, highlightData.sourceType,
highlightData.sourceValue, highlightData.sourceValue,
highlightData.context, highlightData.context,
undefined, // description parameter (not used) undefined,
{ {
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Highlights are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
} }
) )
} else if (isPublicMessage) { }
draftEvent = await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
// Public messages
if (isPublicMessage) {
return await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Public messages are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { } else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
draftEvent = await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, { return await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Public messages are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { }
draftEvent = await createCommentDraftEvent(cleanedText, parentEvent, mentions, {
// Comments and replies
if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
return await createCommentDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag, addClientTag,
protectedEvent: shouldUseProtectedEvent, protectedEvent: shouldUseProtectedEvent,
isNsfw, isNsfw,
@ -572,19 +559,22 @@ export default function PostContent({
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else if (isPoll) { }
draftEvent = await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, {
// Polls
if (isPoll) {
return await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, {
addClientTag, addClientTag,
isNsfw, isNsfw,
addExpirationTag: false, // Polls are not chatting kinds addExpirationTag: false,
expirationMonths, expirationMonths,
addQuietTag, addQuietTag,
quietDays quietDays
}) })
} else { }
// For regular kind 1 note OPs (no parentEvent), never use protectedEvent
// protectedEvent should only be used when replying to an OP that has it // Default: Short text note
draftEvent = await createShortTextNoteDraftEvent(cleanedText, mentions, { return await createShortTextNoteDraftEvent(cleanedText, mentions, {
parentEvent, parentEvent,
addClientTag, addClientTag,
protectedEvent: shouldUseProtectedEvent, protectedEvent: shouldUseProtectedEvent,
@ -594,16 +584,7 @@ export default function PostContent({
addQuietTag, addQuietTag,
quietDays quietDays
}) })
}
// Return formatted JSON
return JSON.stringify(draftEvent, null, 2)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)
}
}, [ }, [
text,
pubkey,
parentEvent, parentEvent,
mediaNoteKind, mediaNoteKind,
mediaUrl, mediaUrl,
@ -624,7 +605,55 @@ export default function PostContent({
isPoll, isPoll,
pollCreateData, pollCreateData,
addClientTag, addClientTag,
isNsfw isNsfw,
articleDTag,
articleTitle,
articleImage,
articleSubject,
articleSummary,
pubkey
])
// Function to generate draft event JSON for preview
const getDraftEventJson = useCallback(async (): Promise<string> => {
// For articles, validate dTag is provided
const isArticle = isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent
if (isArticle && !articleDTag.trim()) {
throw new Error(t('D-Tag is required for articles'))
}
if (!pubkey) {
return JSON.stringify({ error: 'Not logged in' }, null, 2)
}
try {
// Clean tracking parameters from URLs in the post content
const cleanedText = text.replace(
/(https?:\/\/[^\s]+)/g,
(url) => {
try {
return cleanUrl(url)
} catch {
return url
}
}
)
const draftEvent = await createDraftEvent(cleanedText)
return JSON.stringify(draftEvent, null, 2)
} catch (error) {
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2)
}
}, [
text,
pubkey,
isLongFormArticle,
isWikiArticle,
isWikiArticleMarkdown,
isPublicationContent,
articleDTag,
createDraftEvent,
t
]) ])
const post = async (e?: React.MouseEvent) => { const post = async (e?: React.MouseEvent) => {
@ -645,8 +674,8 @@ export default function PostContent({
// }) // })
setPosting(true) setPosting(true)
let draftEvent: any = null
let newEvent: any = null let newEvent: any = null
let draftEvent: any = null
try { try {
// Clean tracking parameters from URLs in the post content // Clean tracking parameters from URLs in the post content
@ -661,240 +690,16 @@ export default function PostContent({
} }
) )
// Get expiration and quiet settings
// Only add expiration tags to chatting kinds: 1, 1111, 1222, 1244
const isChattingKind = (kind: number) =>
kind === kinds.ShortTextNote ||
kind === ExtendedKind.COMMENT ||
kind === ExtendedKind.VOICE ||
kind === ExtendedKind.VOICE_COMMENT
const addExpirationTag = storage.getDefaultExpirationEnabled()
const expirationMonths = storage.getDefaultExpirationMonths()
const addQuietTag = storage.getDefaultQuietEnabled()
const quietDays = storage.getDefaultQuietDays()
// Determine if we should use protected event tag
// Only use it when replying to an OP event that also has the "-" tag
let shouldUseProtectedEvent = false
if (parentEvent) {
// Check if parent event is an OP (not a reply itself) and has the "-" tag
const isParentOP = !isReplyNoteEvent(parentEvent)
const parentHasProtectedTag = isEventProtected(parentEvent)
shouldUseProtectedEvent = isParentOP && parentHasProtectedTag
}
// Determine relay URLs for private events // Determine relay URLs for private events
let privateRelayUrls: string[] = [] let privateRelayUrls: string[] = []
const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt
if (isPrivateEvent) { if (isPrivateEvent) {
if (useCacheOnlyForPrivateNotes && hasCacheRelaysAvailable) {
// Use only cache relays if toggle is ON
privateRelayUrls = await getCacheRelayUrls(pubkey!)
} else {
// Use all private relays (outbox + cache) // Use all private relays (outbox + cache)
privateRelayUrls = await getPrivateRelayUrls(pubkey!) privateRelayUrls = await getPrivateRelayUrls(pubkey!)
} }
}
if (mediaNoteKind !== null && mediaUrl) { // Create draft event using shared function
// Media notes draftEvent = await createDraftEvent(cleanedText)
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) {
// Voice comment
draftEvent = await createVoiceCommentDraftEvent(
cleanedText,
parentEvent,
mediaUrl,
mediaImetaTags,
mentions,
{
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT),
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.VOICE) {
// Voice note
draftEvent = await createVoiceDraftEvent(
cleanedText,
mediaUrl,
mediaImetaTags,
mentions,
{
addClientTag,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE),
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.PICTURE) {
// Picture note
draftEvent = await createPictureDraftEvent(
cleanedText,
mediaImetaTags,
mentions,
{
addClientTag,
isNsfw,
addExpirationTag: false, // Picture notes are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) {
// Video note
draftEvent = await createVideoDraftEvent(
cleanedText,
mediaImetaTags,
mentions,
mediaNoteKind,
{
addClientTag,
isNsfw,
addExpirationTag: false, // Video notes are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
}
} else if (isLongFormArticle) {
draftEvent = await createLongFormArticleDraftEvent(cleanedText, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isWikiArticle) {
draftEvent = await createWikiArticleDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isWikiArticleMarkdown) {
draftEvent = await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Wiki articles are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (isPublicationContent) {
draftEvent = await createPublicationContentDraftEvent(cleanedText, mentions, {
dTag: cleanedText.substring(0, 50).toLowerCase().replace(/[^a-z0-9]/g, '-'), // Simple d-tag from content
addClientTag,
isNsfw,
addExpirationTag: false, // Publication content is not a chatting kind
expirationMonths,
addQuietTag,
quietDays
})
} else if (isCitationInternal) {
// For now, use a simple format - in a real implementation, this would have a form
draftEvent = createCitationInternalDraftEvent(cleanedText, {
cTag: '', // Would need to be filled from a form
title: cleanedText.substring(0, 100)
})
} else if (isCitationExternal) {
draftEvent = createCitationExternalDraftEvent(cleanedText, {
url: '', // Would need to be filled from a form
accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100)
})
} else if (isCitationHardcopy) {
draftEvent = createCitationHardcopyDraftEvent(cleanedText, {
accessedOn: new Date().toISOString(),
title: cleanedText.substring(0, 100)
})
} else if (isCitationPrompt) {
draftEvent = createCitationPromptDraftEvent(cleanedText, {
llm: '', // Would need to be filled from a form
accessedOn: new Date().toISOString()
})
} else if (isHighlight) {
// For highlights, pass the original sourceValue which contains the full identifier
// The createHighlightDraftEvent function will parse it correctly
draftEvent = await createHighlightDraftEvent(
cleanedText,
highlightData.sourceType,
highlightData.sourceValue,
highlightData.context,
undefined, // description parameter (not used)
{
addClientTag,
isNsfw,
addExpirationTag: false, // Highlights are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
}
)
} else if (isPublicMessage) {
draftEvent = await createPublicMessageDraftEvent(cleanedText, extractedMentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Public messages are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) {
draftEvent = await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
isNsfw,
addExpirationTag: false, // Public messages are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) {
draftEvent = await createCommentDraftEvent(cleanedText, parentEvent, mentions, {
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT),
expirationMonths,
addQuietTag,
quietDays
})
} else if (isPoll) {
draftEvent = await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, {
addClientTag,
isNsfw,
addExpirationTag: false, // Polls are not chatting kinds
expirationMonths,
addQuietTag,
quietDays
})
} else {
// For regular kind 1 note OPs (no parentEvent), never use protectedEvent
// protectedEvent should only be used when replying to an OP that has it
draftEvent = await createShortTextNoteDraftEvent(cleanedText, mentions, {
parentEvent,
addClientTag,
protectedEvent: shouldUseProtectedEvent,
isNsfw,
addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote),
expirationMonths,
addQuietTag,
quietDays
})
}
// console.log('Publishing draft event:', draftEvent) // console.log('Publishing draft event:', draftEvent)
// For private events, only publish to private relays // For private events, only publish to private relays
@ -947,7 +752,17 @@ export default function PostContent({
// Remove relayStatuses before storing the event (it's only for UI feedback) // Remove relayStatuses before storing the event (it's only for UI feedback)
const cleanEvent = { ...newEvent } const cleanEvent = { ...newEvent }
delete (cleanEvent as any).relayStatuses delete (cleanEvent as any).relayStatuses
// Add reply immediately so it appears in the thread
if (parentEvent) {
addReplies([cleanEvent]) addReplies([cleanEvent])
// Also dispatch the newEvent to ensure ReplyNoteList picks it up
// The event is already dispatched by publish(), but we do it again to ensure it's caught
setTimeout(() => {
client.emitNewEvent(cleanEvent)
}, 100)
}
close() close()
} catch (error) { } catch (error) {
logger.error('Publishing error', { error }) logger.error('Publishing error', { error })
@ -1475,6 +1290,27 @@ export default function PostContent({
setIsCitationExternal(false) setIsCitationExternal(false)
setIsCitationHardcopy(false) setIsCitationHardcopy(false)
setIsCitationPrompt(false) setIsCitationPrompt(false)
// Clear article metadata when switching off article mode
if (type === null) {
setArticleTitle('')
setArticleDTag('')
setArticleImage('')
setArticleSubject('')
setArticleSummary('')
setArticleSummary('')
}
// Clear article fields when toggling off
if (type === 'longform' || type === 'wiki' || type === 'wiki-markdown' || type === 'publication') {
// Keep fields when switching between article types
} else {
setArticleTitle('')
setArticleDTag('')
setArticleImage('')
setArticleSubject('')
setArticleSummary('')
}
} }
const handleCitationToggle = (type: 'internal' | 'external' | 'hardcopy' | 'prompt') => { const handleCitationToggle = (type: 'internal' | 'external' | 'hardcopy' | 'prompt') => {
@ -1592,6 +1428,86 @@ export default function PostContent({
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
{/* Article metadata fields */}
{(isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent) && (
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="space-y-2">
<Label htmlFor="article-dtag" className="text-sm font-medium">
{t('D-Tag')} <span className="text-destructive">*</span>
</Label>
<Input
id="article-dtag"
value={articleDTag}
onChange={(e) => setArticleDTag(e.target.value)}
placeholder={t('e.g., my-article-title')}
className={!articleDTag.trim() ? 'border-destructive' : ''}
/>
<p className="text-xs text-muted-foreground">
{t('Unique identifier for this article (required)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="article-title" className="text-sm font-medium">
{t('Title')}
</Label>
<Input
id="article-title"
value={articleTitle}
onChange={(e) => setArticleTitle(e.target.value)}
placeholder={t('Article title (optional)')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="article-image" className="text-sm font-medium">
{t('Image URL')}
</Label>
<Input
id="article-image"
value={articleImage}
onChange={(e) => setArticleImage(e.target.value)}
placeholder={t('https://example.com/image.jpg')}
/>
<p className="text-xs text-muted-foreground">
{t('URL of the article cover image (optional)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="article-subject" className="text-sm font-medium">
{t('Subject / Topics')}
</Label>
<Input
id="article-subject"
value={articleSubject}
onChange={(e) => setArticleSubject(e.target.value)}
placeholder={t('topic1, topic2, topic3')}
/>
<p className="text-xs text-muted-foreground">
{t('Comma or space-separated topics (will be added as t-tags)')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="article-summary" className="text-sm font-medium">
{t('Summary')}
</Label>
<Textarea
id="article-summary"
value={articleSummary}
onChange={(e) => setArticleSummary(e.target.value)}
placeholder={t('Brief summary of the article (optional)')}
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t('A short description of the article content')}
</p>
</div>
</div>
)}
<PostTextarea <PostTextarea
ref={textareaRef} ref={textareaRef}
text={text} text={text}

89
src/components/PostEditor/PostTextarea/Preview.tsx

@ -1,6 +1,7 @@
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { ExtendedKind, POLL_TYPE } from '@/constants' import { ExtendedKind, POLL_TYPE } from '@/constants'
import { transformCustomEmojisInContent } from '@/lib/draft-event' import { transformCustomEmojisInContent } from '@/lib/draft-event'
import { normalizeTopic } from '@/lib/discussion-topics'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
@ -12,6 +13,7 @@ import ContentPreview from '../../ContentPreview'
import Content from '../../Content' import Content from '../../Content'
import Highlight from '../../Note/Highlight' import Highlight from '../../Note/Highlight'
import MarkdownArticle from '../../Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../../Note/MarkdownArticle/MarkdownArticle'
import AsciidocArticle from '../../Note/AsciidocArticle/AsciidocArticle'
import { HighlightData } from '../HighlightEditor' import { HighlightData } from '../HighlightEditor'
export default function Preview({ export default function Preview({
@ -21,7 +23,8 @@ export default function Preview({
highlightData, highlightData,
pollCreateData, pollCreateData,
mediaImetaTags, mediaImetaTags,
mediaUrl mediaUrl,
articleMetadata
}: { }: {
content: string content: string
className?: string className?: string
@ -30,6 +33,13 @@ export default function Preview({
pollCreateData?: TPollCreateData pollCreateData?: TPollCreateData
mediaImetaTags?: string[][] mediaImetaTags?: string[][]
mediaUrl?: string mediaUrl?: string
articleMetadata?: {
title?: string
summary?: string
image?: string
dTag?: string
topics?: string[]
}
}) { }) {
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo(
() => { () => {
@ -107,15 +117,36 @@ export default function Preview({
[content, kind, highlightData, pollCreateData] [content, kind, highlightData, pollCreateData]
) )
// Combine emoji tags, highlight tags, poll tags, and media imeta tags // Combine emoji tags, highlight tags, poll tags, media imeta tags, and article metadata tags
const allTags = useMemo(() => { const allTags = useMemo(() => {
const tags = [...emojiTags, ...highlightTags, ...pollTags] const tags = [...emojiTags, ...highlightTags, ...pollTags]
// Add imeta tags for media (voice comments, etc.) // Add imeta tags for media (voice comments, etc.)
if (mediaImetaTags && mediaImetaTags.length > 0) { if (mediaImetaTags && mediaImetaTags.length > 0) {
tags.push(...mediaImetaTags) tags.push(...mediaImetaTags)
} }
// Add article metadata tags for article kinds
if (articleMetadata && (kind === kinds.LongFormArticle || kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || kind === ExtendedKind.PUBLICATION_CONTENT)) {
if (articleMetadata.dTag) {
tags.push(['d', articleMetadata.dTag])
}
if (articleMetadata.title) {
tags.push(['title', articleMetadata.title])
}
if (articleMetadata.summary) {
tags.push(['summary', articleMetadata.summary])
}
if (articleMetadata.image) {
tags.push(['image', articleMetadata.image])
}
if (articleMetadata.topics && articleMetadata.topics.length > 0) {
const normalizedTopics = articleMetadata.topics
.map(topic => normalizeTopic(topic.trim()))
.filter(topic => topic.length > 0)
tags.push(...normalizedTopics.map((topic) => ['t', topic]))
}
}
return tags return tags
}, [emojiTags, highlightTags, pollTags, mediaImetaTags]) }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, kind])
const fakeEvent = useMemo(() => { const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there // For voice comments, include the media URL in content if not already there
@ -169,6 +200,58 @@ export default function Preview({
) )
} }
// For LongFormArticle, use MarkdownArticle
if (kind === kinds.LongFormArticle) {
return (
<Card className={cn('p-3', className)}>
<MarkdownArticle
event={fakeEvent}
className="pointer-events-none"
hideMetadata={true}
/>
</Card>
)
}
// For WikiArticle (AsciiDoc), use AsciidocArticle
if (kind === ExtendedKind.WIKI_ARTICLE) {
return (
<Card className={cn('p-3', className)}>
<AsciidocArticle
event={fakeEvent}
className="pointer-events-none"
hideImagesAndInfo={false}
/>
</Card>
)
}
// For WikiArticleMarkdown, use MarkdownArticle
if (kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
return (
<Card className={cn('p-3', className)}>
<MarkdownArticle
event={fakeEvent}
className="pointer-events-none"
hideMetadata={true}
/>
</Card>
)
}
// For PublicationContent, use AsciidocArticle
if (kind === ExtendedKind.PUBLICATION_CONTENT) {
return (
<Card className={cn('p-3', className)}>
<AsciidocArticle
event={fakeEvent}
className="pointer-events-none"
hideImagesAndInfo={false}
/>
</Card>
)
}
return ( return (
<Card className={cn('p-3', className)}> <Card className={cn('p-3', className)}>
<Content <Content

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

@ -51,6 +51,13 @@ const PostTextarea = forwardRef<
getDraftEventJson?: () => Promise<string> getDraftEventJson?: () => Promise<string>
mediaImetaTags?: string[][] mediaImetaTags?: string[][]
mediaUrl?: string mediaUrl?: string
articleMetadata?: {
title?: string
summary?: string
image?: string
dTag?: string
topics?: string[]
}
} }
>( >(
( (
@ -70,7 +77,8 @@ const PostTextarea = forwardRef<
headerActions, headerActions,
getDraftEventJson, getDraftEventJson,
mediaImetaTags, mediaImetaTags,
mediaUrl mediaUrl,
articleMetadata
}, },
ref ref
) => { ) => {
@ -238,7 +246,7 @@ const PostTextarea = forwardRef<
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
kind {kindDescription.number}: {kindDescription.description} kind {kindDescription.number}: {kindDescription.description}
</div> </div>
<Preview content={text} className={className} kind={kind} highlightData={highlightData} pollCreateData={pollCreateData} mediaImetaTags={mediaImetaTags} mediaUrl={mediaUrl} /> <Preview content={text} className={className} kind={kind} highlightData={highlightData} pollCreateData={pollCreateData} mediaImetaTags={mediaImetaTags} mediaUrl={mediaUrl} articleMetadata={articleMetadata} />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="json"> <TabsContent value="json">

1
src/constants.ts

@ -48,7 +48,6 @@ export const StorageKey = {
SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS: 'shownCreateWalletGuideToastPubkeys', SHOWN_CREATE_WALLET_GUIDE_TOAST_PUBKEYS: 'shownCreateWalletGuideToastPubkeys',
SHOW_RECOMMENDED_RELAYS_PANEL: 'showRecommendedRelaysPanel', SHOW_RECOMMENDED_RELAYS_PANEL: 'showRecommendedRelaysPanel',
DEFAULT_EXPIRATION_ENABLED: 'defaultExpirationEnabled', DEFAULT_EXPIRATION_ENABLED: 'defaultExpirationEnabled',
USE_CACHE_ONLY_FOR_PRIVATE_NOTES: 'useCacheOnlyForPrivateNotes',
DEFAULT_EXPIRATION_MONTHS: 'defaultExpirationMonths', DEFAULT_EXPIRATION_MONTHS: 'defaultExpirationMonths',
DEFAULT_QUIET_ENABLED: 'defaultQuietEnabled', DEFAULT_QUIET_ENABLED: 'defaultQuietEnabled',
DEFAULT_QUIET_DAYS: 'defaultQuietDays', DEFAULT_QUIET_DAYS: 'defaultQuietDays',

12
src/index.css

@ -33,6 +33,18 @@
user-select: none; user-select: none;
} }
/* Allow text selection in note content */
[data-note-content],
.note-content,
article,
.markdown-content,
.prose {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
.clickable { .clickable {
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;

34
src/lib/draft-event.ts

@ -3,7 +3,7 @@ import client from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
import { prefixNostrAddresses } from '@/lib/nostr-address' import { prefixNostrAddresses } from '@/lib/nostr-address'
import { normalizeHashtag } from '@/lib/discussion-topics' import { normalizeHashtag, normalizeTopic } from '@/lib/discussion-topics'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
TDraftEvent, TDraftEvent,
@ -1418,6 +1418,7 @@ export async function createLongFormArticleDraftEvent(
image?: string image?: string
publishedAt?: number publishedAt?: number
dTag?: string dTag?: string
topics?: string[]
addClientTag?: boolean addClientTag?: boolean
isNsfw?: boolean isNsfw?: boolean
addExpirationTag?: boolean addExpirationTag?: boolean
@ -1447,6 +1448,13 @@ export async function createLongFormArticleDraftEvent(
} }
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
// Add topics as t-tags directly
if (options.topics && options.topics.length > 0) {
const normalizedTopics = options.topics
.map(topic => normalizeTopic(topic.trim()))
.filter(topic => topic.length > 0)
tags.push(...normalizedTopics.map((topic) => buildTTag(topic)))
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
// imeta tags for images in content // imeta tags for images in content
@ -1496,6 +1504,7 @@ export async function createWikiArticleDraftEvent(
title?: string title?: string
summary?: string summary?: string
image?: string image?: string
topics?: string[]
addClientTag?: boolean addClientTag?: boolean
isNsfw?: boolean isNsfw?: boolean
addExpirationTag?: boolean addExpirationTag?: boolean
@ -1520,6 +1529,13 @@ export async function createWikiArticleDraftEvent(
} }
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
// Add topics as t-tags directly
if (options.topics && options.topics.length > 0) {
const normalizedTopics = options.topics
.map(topic => normalizeTopic(topic.trim()))
.filter(topic => topic.length > 0)
tags.push(...normalizedTopics.map((topic) => buildTTag(topic)))
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) { if (options.addClientTag) {
@ -1554,6 +1570,7 @@ export async function createWikiArticleMarkdownDraftEvent(
title?: string title?: string
summary?: string summary?: string
image?: string image?: string
topics?: string[]
addClientTag?: boolean addClientTag?: boolean
isNsfw?: boolean isNsfw?: boolean
addExpirationTag?: boolean addExpirationTag?: boolean
@ -1578,6 +1595,13 @@ export async function createWikiArticleMarkdownDraftEvent(
} }
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
// Add topics as t-tags directly
if (options.topics && options.topics.length > 0) {
const normalizedTopics = options.topics
.map(topic => normalizeTopic(topic.trim()))
.filter(topic => topic.length > 0)
tags.push(...normalizedTopics.map((topic) => buildTTag(topic)))
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) { if (options.addClientTag) {
@ -1612,6 +1636,7 @@ export async function createPublicationContentDraftEvent(
title?: string title?: string
summary?: string summary?: string
image?: string image?: string
topics?: string[]
addClientTag?: boolean addClientTag?: boolean
isNsfw?: boolean isNsfw?: boolean
addExpirationTag?: boolean addExpirationTag?: boolean
@ -1636,6 +1661,13 @@ export async function createPublicationContentDraftEvent(
} }
tags.push(...emojiTags) tags.push(...emojiTags)
tags.push(...hashtags.map((hashtag) => buildTTag(hashtag))) tags.push(...hashtags.map((hashtag) => buildTTag(hashtag)))
// Add topics as t-tags directly
if (options.topics && options.topics.length > 0) {
const normalizedTopics = options.topics
.map(topic => normalizeTopic(topic.trim()))
.filter(topic => topic.length > 0)
tags.push(...normalizedTopics.map((topic) => buildTTag(topic)))
}
tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) { if (options.addClientTag) {

2
src/lib/kind-description.ts

@ -43,7 +43,7 @@ export function getKindDescription(kind: number): { number: number; description:
case ExtendedKind.POLL: case ExtendedKind.POLL:
return { number: 1068, description: 'Poll' } return { number: 1068, description: 'Poll' }
case ExtendedKind.PUBLIC_MESSAGE: case ExtendedKind.PUBLIC_MESSAGE:
return { number: 14, description: 'Public Message' } return { number: 24, description: 'Public Message' }
default: default:
return { number: kind, description: `Event (kind ${kind})` } return { number: kind, description: `Event (kind ${kind})` }
} }

92
src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx

@ -1,92 +0,0 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { StorageKey, ExtendedKind } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
import indexedDb from '@/services/indexed-db.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function CacheRelayOnlySetting() {
const { t } = useTranslation()
const { cacheRelayListEvent, pubkey } = useNostr()
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false)
const [enabled, setEnabled] = useState(false) // Start as OFF, will be updated based on cache availability
// Check if user has cache relays - check both provider state and IndexedDB as fallback
// Note: Cache relay events use 'r' tags, not 'relay' tags
useEffect(() => {
const checkCacheRelays = async () => {
let hasRelays = false
// First check provider state
if (cacheRelayListEvent) {
hasRelays = cacheRelayListEvent.tags.some(tag => tag[0] === 'r' && tag[1])
} else if (pubkey) {
// Fallback: check IndexedDB directly if provider state isn't loaded yet
try {
const storedEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)
if (storedEvent) {
hasRelays = storedEvent.tags.some(tag => tag[0] === 'r' && tag[1])
}
} catch (error) {
// Ignore errors
}
}
setHasCacheRelaysAvailable(hasRelays)
// Set enabled state based on cache availability
if (hasRelays) {
// If cache exists, default to true (ON)
// Only respect localStorage if it's explicitly set to 'false' by the user
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
// Default to ON when cache exists - only set to OFF if user explicitly set it to 'false'
if (stored === 'false') {
setEnabled(false)
} else {
// Default to ON (either null or 'true')
setEnabled(true)
// Save the default ON state if not already set
if (stored === null) {
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, 'true')
}
}
} else {
// If no cache, set to false (OFF) and save it
setEnabled(false)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, 'false')
}
}
checkCacheRelays()
}, [cacheRelayListEvent, pubkey])
const handleEnabledChange = (checked: boolean) => {
setEnabled(checked)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, checked.toString())
}
if (!hasCacheRelaysAvailable) {
return null // Don't show if user doesn't have cache relays
}
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{t('Private Notes')}</h3>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="cache-relay-only">{t('Use cache relay only for citations and publication content')}</Label>
<Switch
id="cache-relay-only"
checked={enabled}
onCheckedChange={handleEnabledChange}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('When enabled, citations and publication content (kind 30041) will only be published to your cache relay, not to outbox relays')}
</div>
</div>
</div>
)
}

2
src/pages/secondary/PostSettingsPage/index.tsx

@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import MediaUploadServiceSetting from './MediaUploadServiceSetting' import MediaUploadServiceSetting from './MediaUploadServiceSetting'
import ExpirationSettings from './ExpirationSettings' import ExpirationSettings from './ExpirationSettings'
import QuietSettings from './QuietSettings' import QuietSettings from './QuietSettings'
import CacheRelayOnlySetting from './CacheRelayOnlySetting'
const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -21,7 +20,6 @@ const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<h3 className="text-lg font-medium">{t('Quiet Tags')}</h3> <h3 className="text-lg font-medium">{t('Quiet Tags')}</h3>
<QuietSettings /> <QuietSettings />
</div> </div>
<CacheRelayOnlySetting />
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
) )

Loading…
Cancel
Save