You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

1184 lines
44 KiB

import Note from '@/components/Note'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
createCommentDraftEvent,
createPollDraftEvent,
createPublicMessageDraftEvent,
createPublicMessageReplyDraftEvent,
createShortTextNoteDraftEvent,
createHighlightDraftEvent,
deleteDraftEventCache,
createVoiceDraftEvent,
createVoiceCommentDraftEvent,
createPictureDraftEvent,
createVideoDraftEvent,
createLongFormArticleDraftEvent,
createWikiArticleDraftEvent,
createWikiArticleMarkdownDraftEvent,
createPublicationContentDraftEvent,
createCitationInternalDraftEvent,
createCitationExternalDraftEvent,
createCitationHardcopyDraftEvent,
createCitationPromptDraftEvent
} from '@/lib/draft-event'
import { ExtendedKind } from '@/constants'
import { isTouchDevice } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useFeed } from '@/providers/FeedProvider'
import { useReply } from '@/providers/ReplyProvider'
import { normalizeUrl, cleanUrl } from '@/lib/url'
import logger from '@/lib/logger'
import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service'
import { TPollCreateData } from '@/types'
import { ImageUp, ListTodo, LoaderCircle, MessageCircle, Settings, Smile, X, Highlighter, FileText, Quote, Upload } from 'lucide-react'
import { getMediaKindFromFile } from '@/lib/media-kind-detection'
import { hasPrivateRelays, getPrivateRelayUrls, hasCacheRelays, getCacheRelayUrls } from '@/lib/private-relays'
import mediaUpload from '@/services/media-upload.service'
import { StorageKey } from '@/constants'
import { isProtectedEvent as isEventProtected, isReplyNoteEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import EmojiPickerDialog from '../EmojiPickerDialog'
import Mentions, { extractMentions } from './Mentions'
import PollEditor from './PollEditor'
import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import Uploader from './Uploader'
import HighlightEditor, { HighlightData } from './HighlightEditor'
export default function PostContent({
defaultContent = '',
parentEvent,
close,
openFrom,
initialHighlightData
}: {
defaultContent?: string
parentEvent?: Event
close: () => void
openFrom?: string[]
initialHighlightData?: HighlightData
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
const { feedInfo } = useFeed()
const { addReplies } = useReply()
const [text, setText] = useState('')
const textareaRef = useRef<TPostTextareaHandle>(null)
const [posting, setPosting] = useState(false)
const [uploadProgresses, setUploadProgresses] = useState<
{ file: File; progress: number; cancel: () => void }[]
>([])
const [showMoreOptions, setShowMoreOptions] = useState(false)
const [addClientTag, setAddClientTag] = useState(true) // Default to true to always add client tag
const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false)
const [isPoll, setIsPoll] = useState(false)
const [isPublicMessage, setIsPublicMessage] = useState(false)
const [extractedMentions, setExtractedMentions] = useState<string[]>([])
const [isProtectedEvent, setIsProtectedEvent] = useState(false)
const [additionalRelayUrls, setAdditionalRelayUrls] = useState<string[]>([])
const [isHighlight, setIsHighlight] = useState(!!initialHighlightData)
const [highlightData, setHighlightData] = useState<HighlightData>(
initialHighlightData || {
sourceType: 'nostr',
sourceValue: ''
}
)
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
isMultipleChoice: false,
options: ['', ''],
endsAt: undefined,
relays: []
})
const [minPow, setMinPow] = useState(0)
const [mediaNoteKind, setMediaNoteKind] = useState<number | null>(null)
const [mediaImetaTags, setMediaImetaTags] = useState<string[][]>([])
const [mediaUrl, setMediaUrl] = useState<string>('')
const [isLongFormArticle, setIsLongFormArticle] = useState(false)
const [isWikiArticle, setIsWikiArticle] = useState(false)
const [isWikiArticleMarkdown, setIsWikiArticleMarkdown] = useState(false)
const [isPublicationContent, setIsPublicationContent] = useState(false)
const [isCitationInternal, setIsCitationInternal] = useState(false)
const [isCitationExternal, setIsCitationExternal] = useState(false)
const [isCitationHardcopy, setIsCitationHardcopy] = useState(false)
const [isCitationPrompt, setIsCitationPrompt] = useState(false)
const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false)
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false)
const [useCacheOnlyForPrivateNotes, setUseCacheOnlyForPrivateNotes] = useState(true) // Default ON
const uploadedMediaFileMap = useRef<Map<string, File>>(new Map())
const isFirstRender = useRef(true)
const canPost = useMemo(() => {
const result = (
!!pubkey &&
!posting &&
!uploadProgresses.length &&
// 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() !== '')
)
return result
}, [
pubkey,
text,
posting,
uploadProgresses,
mediaNoteKind,
mediaUrl,
isPoll,
pollCreateData,
isPublicMessage,
extractedMentions,
parentEvent?.kind,
isProtectedEvent,
additionalRelayUrls,
isHighlight,
highlightData
])
// Clear highlight data when initialHighlightData changes or is removed
useEffect(() => {
if (initialHighlightData) {
// Set highlight mode and data when provided
setIsHighlight(true)
setHighlightData(initialHighlightData)
} else {
// Clear highlight mode and data when not provided
setIsHighlight(false)
setHighlightData({
sourceType: 'nostr',
sourceValue: ''
})
}
}, [initialHighlightData])
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
const cachedSettings = postEditorCache.getPostSettingsCache({
defaultContent,
parentEvent
})
if (cachedSettings) {
setIsNsfw(cachedSettings.isNsfw ?? false)
setIsPoll(cachedSettings.isPoll ?? false)
setPollCreateData(
cachedSettings.pollCreateData ?? {
isMultipleChoice: false,
options: ['', ''],
endsAt: undefined,
relays: []
}
)
setAddClientTag(cachedSettings.addClientTag ?? true) // Default to true
}
return
}
postEditorCache.setPostSettingsCache(
{ defaultContent, parentEvent },
{
isNsfw,
isPoll,
pollCreateData,
addClientTag
}
)
}, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag])
// Extract mentions from content for public messages
const extractMentionsFromContent = useCallback(async (content: string) => {
try {
// Extract nostr: protocol mentions
const { pubkeys: nostrPubkeys } = await extractMentions(content, undefined)
// For now, we'll use the nostr mentions
// In a real implementation, you'd also resolve @ mentions to pubkeys
setExtractedMentions(nostrPubkeys)
} catch (error) {
logger.error('Error extracting mentions', { error })
setExtractedMentions([])
}
}, [])
useEffect(() => {
if (!text) {
setExtractedMentions([])
return
}
// Debounce the mention extraction for all posts (not just public messages)
const timeoutId = setTimeout(() => {
extractMentionsFromContent(text)
}, 300)
return () => {
clearTimeout(timeoutId)
}
}, [text, extractMentionsFromContent])
// Check for private relays availability
useEffect(() => {
if (!pubkey) {
setHasPrivateRelaysAvailable(false)
setHasCacheRelaysAvailable(false)
return
}
hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => {
setHasPrivateRelaysAvailable(false)
})
hasCacheRelays(pubkey).then(setHasCacheRelaysAvailable).catch(() => {
setHasCacheRelaysAvailable(false)
})
}, [pubkey])
// Load cache-only preference from localStorage
useEffect(() => {
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
// Default to true (ON) if not set
setUseCacheOnlyForPrivateNotes(stored === null ? true : stored === 'true')
}, [])
const post = async (e?: React.MouseEvent) => {
e?.stopPropagation()
checkLogin(async () => {
if (!canPost) {
logger.warn('Attempted to post while canPost is false')
return
}
// console.log('🚀 Starting post process:', {
// isPublicMessage,
// parentEventKind: parentEvent?.kind,
// parentEventId: parentEvent?.id,
// text: text.substring(0, 50) + '...',
// mentions: mentions.length,
// canPost
// })
setPosting(true)
let draftEvent: any = null
let newEvent: any = null
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
// 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
let privateRelayUrls: string[] = []
const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt
if (isPrivateEvent) {
if (useCacheOnlyForPrivateNotes && hasCacheRelaysAvailable) {
// Use only cache relays if toggle is ON
privateRelayUrls = await getCacheRelayUrls(pubkey!)
} else {
// Use all private relays (outbox + cache)
privateRelayUrls = await getPrivateRelayUrls(pubkey!)
}
}
if (mediaNoteKind !== null && mediaUrl) {
// Media notes
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)
// For private events, only publish to private relays
const relayUrls = isPrivateEvent && privateRelayUrls.length > 0
? privateRelayUrls
: (additionalRelayUrls.length > 0 ? additionalRelayUrls : undefined)
newEvent = await publish(draftEvent, {
specifiedRelayUrls: relayUrls,
additionalRelayUrls: isPoll ? pollCreateData.relays : (isPrivateEvent ? privateRelayUrls : additionalRelayUrls),
minPow,
disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent // Don't use fallbacks if user explicitly selected relays or for private events
})
// console.log('Published event:', newEvent)
// Check if we need to refresh the current relay view
if (feedInfo.feedType === 'relay' && feedInfo.id) {
const currentRelayUrl = normalizeUrl(feedInfo.id)
const publishedRelays = additionalRelayUrls
// If we published to the current relay being viewed, trigger a refresh after a short delay
if (publishedRelays.some(url => normalizeUrl(url) === currentRelayUrl)) {
setTimeout(() => {
// Trigger a page refresh by dispatching a custom event that the relay view can listen to
window.dispatchEvent(new CustomEvent('relay-refresh-needed', {
detail: { relayUrl: currentRelayUrl }
}))
}, 1000) // 1 second delay to allow the event to propagate
}
}
// Show publishing feedback
if ((newEvent as any).relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (newEvent as any).relayStatuses,
successCount: (newEvent as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (newEvent as any).relayStatuses.length
}, {
message: parentEvent ? t('Reply published') : t('Post published'),
duration: 6000
})
} else {
showSimplePublishSuccess(parentEvent ? t('Reply published') : t('Post published'))
}
// Full success - clean up and close
postEditorCache.clearPostCache({ defaultContent, parentEvent })
deleteDraftEventCache(draftEvent)
// Remove relayStatuses before storing the event (it's only for UI feedback)
const cleanEvent = { ...newEvent }
delete (cleanEvent as any).relayStatuses
addReplies([cleanEvent])
close()
} catch (error) {
logger.error('Publishing error', { error })
logger.error('Publishing error details', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
})
// Check if we have relay statuses to display (even if publishing failed)
if (error instanceof AggregateError && (error as any).relayStatuses) {
const relayStatuses = (error as any).relayStatuses
const successCount = relayStatuses.filter((s: any) => s.success).length
const totalCount = relayStatuses.length
// Show proper relay status feedback
showPublishingFeedback({
success: successCount > 0,
relayStatuses,
successCount,
totalCount
}, {
message: successCount > 0 ?
(parentEvent ? t('Reply published to some relays') : t('Post published to some relays')) :
(parentEvent ? t('Failed to publish reply') : t('Failed to publish post')),
duration: 6000
})
// Handle partial success
if (successCount > 0) {
// Clean up and close on partial success
postEditorCache.clearPostCache({ defaultContent, parentEvent })
if (draftEvent) deleteDraftEventCache(draftEvent)
if (newEvent) addReplies([newEvent])
close()
}
} else {
// Use standard publishing error feedback for cases without relay statuses
if (error instanceof AggregateError) {
const errorMessages = error.errors.map((err: any) => err.message).join('; ')
showPublishingError(`Failed to publish to relays: ${errorMessages}`)
} else if (error instanceof Error) {
showPublishingError(error.message)
} else {
showPublishingError('Failed to publish')
}
// Don't close form on complete failure - let user try again
}
} finally {
setPosting(false)
}
})
}
const handlePollToggle = () => {
if (parentEvent) return
setIsPoll((prev) => !prev)
if (!isPoll) {
// When enabling poll mode, clear other modes
setIsPublicMessage(false)
setIsHighlight(false)
}
}
const handlePublicMessageToggle = () => {
if (parentEvent) return
setIsPublicMessage((prev) => !prev)
if (!isPublicMessage) {
// When enabling public message mode, clear other modes
setIsPoll(false)
setIsHighlight(false)
}
}
const handleHighlightToggle = () => {
if (parentEvent) return
setIsHighlight((prev) => !prev)
if (!isHighlight) {
// When enabling highlight mode, clear other modes and set client tag to true
setIsPoll(false)
setIsPublicMessage(false)
setAddClientTag(true)
}
}
const handleUploadStart = (file: File, cancel: () => void) => {
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
// Track file for media upload
if (file.type.startsWith('image/') || file.type.startsWith('audio/') || file.type.startsWith('video/')) {
uploadedMediaFileMap.current.set(file.name, file)
}
}
const handleUploadProgress = (file: File, progress: number) => {
setUploadProgresses((prev) =>
prev.map((item) => (item.file === file ? { ...item, progress } : item))
)
}
const handleUploadEnd = (file: File) => {
setUploadProgresses((prev) => prev.filter((item) => item.file !== file))
// Keep file in map until upload success is called
}
const handleMediaUploadSuccess = async ({ url, tags }: { url: string; tags: string[][] }) => {
// Find the file from the map - try to match by URL or get the most recent
let uploadingFile: File | undefined
// Try to find by matching URL pattern or get the first available
for (const [, file] of uploadedMediaFileMap.current.entries()) {
uploadingFile = file
break // Get first available
}
if (!uploadingFile) {
// Try to get from uploadProgresses as fallback
const progressItem = uploadProgresses.find(p => p.file)
uploadingFile = progressItem?.file
}
if (!uploadingFile) {
logger.warn('Media upload succeeded but file not found')
return
}
// Determine media kind from file
// For replies, only audio comments are supported (kind 1244)
// For new posts, all media types are supported
if (parentEvent) {
// For replies, only allow audio comments
const fileType = uploadingFile.type
const fileName = uploadingFile.name.toLowerCase()
const isAudio = fileType.startsWith('audio/') || /\.(mp3|m4a|ogg|wav|webm|opus|aac|flac)$/i.test(fileName)
if (isAudio) {
// For replies, always create voice comments, regardless of duration
setMediaNoteKind(ExtendedKind.VOICE_COMMENT)
} else {
// Non-audio media in replies - don't set mediaNoteKind, will be handled as regular comment
// Clear any existing media note kind
setMediaNoteKind(null)
setMediaUrl('')
setMediaImetaTags([])
// Just add the media URL to the text content
textareaRef.current?.appendText(url, true)
return // Don't set media note kind for non-audio in replies
}
} else {
// For new posts, use the detected kind (which handles audio > 60s → video)
const kind = await getMediaKindFromFile(uploadingFile, false)
setMediaNoteKind(kind)
}
setMediaUrl(url)
// Get imeta tag from media upload service
const imetaTag = mediaUpload.getImetaTagByUrl(url)
if (imetaTag) {
// imetaTag is already a string[] like ['imeta', 'url https://...', 'm image/jpeg']
// We need it as string[][] for the draft event functions
setMediaImetaTags([imetaTag])
} else if (tags && tags.length > 0) {
// Use tags from upload result - they should already be in the right format
setMediaImetaTags(tags)
} else {
// Create a basic imeta tag if none exists
const basicImetaTag: string[] = ['imeta', `url ${url}`]
if (uploadingFile.type) {
basicImetaTag.push(`m ${uploadingFile.type}`)
}
setMediaImetaTags([basicImetaTag])
}
// Clear other note types when media is selected
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsPublicationContent(false)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
// Clear uploaded file from map
uploadedMediaFileMap.current.clear()
}
const handleArticleToggle = (type: 'longform' | 'wiki' | 'wiki-markdown' | 'publication') => {
if (parentEvent) return // Can't create articles as replies
setIsLongFormArticle(type === 'longform')
setIsWikiArticle(type === 'wiki')
setIsWikiArticleMarkdown(type === 'wiki-markdown')
setIsPublicationContent(type === 'publication')
// Clear other types
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setMediaNoteKind(null)
setIsCitationInternal(false)
setIsCitationExternal(false)
setIsCitationHardcopy(false)
setIsCitationPrompt(false)
}
const handleCitationToggle = (type: 'internal' | 'external' | 'hardcopy' | 'prompt') => {
if (parentEvent) return // Can't create citations as replies
setIsCitationInternal(type === 'internal')
setIsCitationExternal(type === 'external')
setIsCitationHardcopy(type === 'hardcopy')
setIsCitationPrompt(type === 'prompt')
// Clear other types
setIsPoll(false)
setIsPublicMessage(false)
setIsHighlight(false)
setMediaNoteKind(null)
setIsLongFormArticle(false)
setIsWikiArticle(false)
setIsWikiArticleMarkdown(false)
setIsPublicationContent(false)
}
return (
<div className="space-y-2">
{/* Dynamic Title based on mode */}
<div className="text-lg font-semibold">
{parentEvent ? (
<div className="flex gap-2 items-center w-full">
<div className="shrink-0">
{parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE
? t('Reply to Public Message')
: mediaNoteKind === ExtendedKind.VOICE_COMMENT
? t('Voice Comment')
: t('Reply to')
}
</div>
</div>
) : mediaNoteKind === ExtendedKind.VOICE ? (
t('Voice Note')
) : mediaNoteKind === ExtendedKind.PICTURE ? (
t('Picture Note')
) : mediaNoteKind === ExtendedKind.VIDEO ? (
t('Video Note')
) : mediaNoteKind === ExtendedKind.SHORT_VIDEO ? (
t('Short Video Note')
) : isPoll ? (
t('New Poll')
) : isPublicMessage ? (
t('New Public Message')
) : isHighlight ? (
t('New Highlight')
) : isLongFormArticle ? (
t('New Long-form Article')
) : isWikiArticle ? (
t('New Wiki Article')
) : isWikiArticleMarkdown ? (
t('New Wiki Article (Markdown)')
) : isPublicationContent ? (
t('Take a note')
) : isCitationInternal ? (
t('New Internal Citation')
) : isCitationExternal ? (
t('New External Citation')
) : isCitationHardcopy ? (
t('New Hardcopy Citation')
) : isCitationPrompt ? (
t('New Prompt Citation')
) : (
t('New Note')
)}
</div>
{parentEvent && (
<ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40">
<div className="p-2 sm:p-3 pointer-events-none">
<Note size="small" event={parentEvent} hideParentNotePreview />
</div>
</ScrollArea>
)}
<PostTextarea
ref={textareaRef}
text={text}
setText={setText}
defaultContent={defaultContent}
parentEvent={parentEvent}
onSubmit={() => post()}
className={isPoll ? 'min-h-20' : 'min-h-52'}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
kind={
mediaNoteKind !== null
? mediaNoteKind
: isHighlight
? kinds.Highlights
: isPublicMessage
? ExtendedKind.PUBLIC_MESSAGE
: isPoll
? ExtendedKind.POLL
: isLongFormArticle
? kinds.LongFormArticle
: isWikiArticle
? ExtendedKind.WIKI_ARTICLE
: isWikiArticleMarkdown
? ExtendedKind.WIKI_ARTICLE_MARKDOWN
: isPublicationContent
? ExtendedKind.PUBLICATION_CONTENT
: kinds.ShortTextNote
}
highlightData={isHighlight ? highlightData : undefined}
pollCreateData={isPoll ? pollCreateData : undefined}
headerActions={
<>
{/* Media button */}
<Uploader
onUploadSuccess={handleMediaUploadSuccess}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="image/*,audio/*,video/*"
>
<Button
variant="ghost"
size="icon"
title={t('Upload Media')}
className={mediaNoteKind !== null ? 'bg-accent' : ''}
>
<Upload className="h-4 w-4" />
</Button>
</Uploader>
{/* Note creation buttons - only show when not replying */}
{!parentEvent && (
<>
<Button
variant="ghost"
size="icon"
title={t('Create Highlight')}
className={isHighlight ? 'bg-accent' : ''}
onClick={handleHighlightToggle}
>
<Highlighter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={t('Send Public Message')}
className={isPublicMessage ? 'bg-accent' : ''}
onClick={handlePublicMessageToggle}
>
<MessageCircle className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={t('Create Poll')}
className={isPoll ? 'bg-accent' : ''}
onClick={handlePollToggle}
>
<ListTodo className="h-4 w-4" />
</Button>
{/* Article dropdown - only show if has private relays for publication content */}
{(hasPrivateRelaysAvailable || !isPublicationContent) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t('Create Article')}
className={
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent
? 'bg-accent'
: ''
}
>
<FileText className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleArticleToggle('longform')}>
{t('Long-form Article')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki')}>
{t('Wiki Article (AsciiDoc)')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArticleToggle('wiki-markdown')}>
{t('Wiki Article (Markdown)')}
</DropdownMenuItem>
{hasPrivateRelaysAvailable && (
<DropdownMenuItem onClick={() => handleArticleToggle('publication')}>
{t('Take a note')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Citation dropdown - only show if has private relays */}
{hasPrivateRelaysAvailable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t('Create Citation')}
className={
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt
? 'bg-accent'
: ''
}
>
<Quote className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleCitationToggle('internal')}>
{t('Internal Citation')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('external')}>
{t('External Citation')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('hardcopy')}>
{t('Hardcopy Citation')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCitationToggle('prompt')}>
{t('Prompt Citation')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</>
}
/>
{isPoll && (
<PollEditor
pollCreateData={pollCreateData}
setPollCreateData={setPollCreateData}
setIsPoll={setIsPoll}
/>
)}
{isHighlight && (
<HighlightEditor
highlightData={highlightData}
setHighlightData={setHighlightData}
setIsHighlight={setIsHighlight}
/>
)}
{isPublicMessage && (
<div className="rounded-lg border bg-muted/40 p-3">
<div className="mb-2 text-sm font-medium">{t('Recipients')}</div>
<div className="space-y-2">
<Mentions
content={text}
parentEvent={undefined}
mentions={extractedMentions}
setMentions={setExtractedMentions}
/>
{extractedMentions.length > 0 ? (
<div className="text-sm text-muted-foreground">
{t('Recipients detected from your message:')} {extractedMentions.length}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t('Add recipients using nostr: mentions (e.g., nostr:npub1...) or the recipient selector above')}
</div>
)}
</div>
</div>
)}
{uploadProgresses.length > 0 &&
uploadProgresses.map(({ file, progress, cancel }, index) => (
<div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-muted-foreground mb-1">
{file.name ?? t('Uploading...')}
</div>
<div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-[width] duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<button
type="button"
onClick={() => {
cancel?.()
handleUploadEnd(file)
}}
className="text-muted-foreground hover:text-foreground"
title={t('Cancel')}
>
<X className="h-4 w-4" />
</button>
</div>
))}
{!isPoll && (
<PostRelaySelector
setIsProtectedEvent={setIsProtectedEvent}
setAdditionalRelayUrls={setAdditionalRelayUrls}
parentEvent={parentEvent}
openFrom={openFrom}
content={text}
isPublicMessage={isPublicMessage}
mentions={extractedMentions}
/>
)}
<div className="flex items-center justify-between">
<div className="flex gap-2 items-center">
<Uploader
onUploadSuccess={({ url }) => {
textareaRef.current?.appendText(url, true)
}}
onUploadStart={handleUploadStart}
onUploadEnd={handleUploadEnd}
onProgress={handleUploadProgress}
accept="image/*"
>
<Button variant="ghost" size="icon" title={t('Upload Image')}>
<ImageUp />
</Button>
</Uploader>
{/* I'm not sure why, but after triggering the virtual keyboard,
opening the emoji picker drawer causes an issue,
the emoji I tap isn't the one that gets inserted. */}
{!isTouchDevice() && (
<EmojiPickerDialog
onEmojiClick={(emoji) => {
if (!emoji) return
textareaRef.current?.insertEmoji(emoji)
}}
>
<Button variant="ghost" size="icon">
<Smile />
</Button>
</EmojiPickerDialog>
)}
<Button
variant="ghost"
size="icon"
className={showMoreOptions ? 'bg-accent' : ''}
onClick={() => setShowMoreOptions((pre) => !pre)}
>
<Settings />
</Button>
</div>
<div className="flex gap-2 items-center">
<Mentions
content={text}
parentEvent={parentEvent}
mentions={mentions}
setMentions={setMentions}
/>
<div className="flex gap-2 items-center max-sm:hidden">
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : isPublicMessage ? t('Send Public Message') : t('Post')}
</Button>
</div>
</div>
</div>
<PostOptions
posting={posting}
show={showMoreOptions}
addClientTag={addClientTag}
setAddClientTag={setAddClientTag}
isNsfw={isNsfw}
setIsNsfw={setIsNsfw}
minPow={minPow}
setMinPow={setMinPow}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button
className="w-full"
variant="secondary"
onClick={(e) => {
e.stopPropagation()
close()
}}
>
{t('Cancel')}
</Button>
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentEvent ? t('Reply') : t('Post')}
</Button>
</div>
</div>
)
}