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.
3374 lines
133 KiB
3374 lines
133 KiB
import Note from '@/components/Note' |
|
import { Button } from '@/components/ui/button' |
|
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 { Separator } from '@/components/ui/separator' |
|
import { Skeleton } from '@/components/ui/skeleton' |
|
import { |
|
DropdownMenu, |
|
DropdownMenuContent, |
|
DropdownMenuItem, |
|
DropdownMenuLabel, |
|
DropdownMenuSeparator, |
|
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, |
|
applyImwaldAttributionTags |
|
} from '@/lib/draft-event' |
|
import { ExtendedKind } from '@/constants' |
|
import { cn, isTouchDevice } from '@/lib/utils' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { useFeed } from '@/providers/FeedProvider' |
|
import { useReply } from '@/providers/ReplyProvider' |
|
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' |
|
import { normalizeUrl, rewritePlainTextHttpUrls } from '@/lib/url' |
|
import logger from '@/lib/logger' |
|
import { LoginRequiredError } from '@/lib/nostr-errors' |
|
import postEditorCache from '@/services/post-editor-cache.service' |
|
import storage from '@/services/local-storage.service' |
|
import { TPollCreateData } from '@/types' |
|
import { |
|
Book, |
|
Check, |
|
ChevronDown, |
|
ImageUp, |
|
ListTodo, |
|
MessageCircle, |
|
MessagesSquare, |
|
Settings, |
|
Smile, |
|
Users, |
|
X, |
|
Highlighter, |
|
FileText, |
|
HelpCircle, |
|
Quote, |
|
StickyNote, |
|
Upload, |
|
Mic, |
|
Music, |
|
Video, |
|
Film, |
|
Laugh |
|
} from 'lucide-react' |
|
import { fileLooksLikeUploadableMedia } from '@/lib/compress-upload-media' |
|
import { getMediaKindFromFile } from '@/lib/media-kind-detection' |
|
import { hasPrivateRelays, getPrivateRelayUrls } from '@/lib/private-relays' |
|
import mediaUpload from '@/services/media-upload.service' |
|
import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publish-relay-urls' |
|
import client, { eventService } from '@/services/client.service' |
|
import discussionFeedCache from '@/services/discussion-feed-cache.service' |
|
import noteStatsService from '@/services/note-stats.service' |
|
import { |
|
buildAllAvailableTopics, |
|
collectDiscussionThreadTags, |
|
discussionThreadDraftKindParams, |
|
displayTopicLabel, |
|
resolveTopicFromInput, |
|
THREAD_POST_EDITOR_PARENT, |
|
type TDiscussionDynamicTopics |
|
} from '@/lib/discussion-thread-composer' |
|
import { prefixNostrAddresses } from '@/lib/nostr-address' |
|
import dayjs from 'dayjs' |
|
import { TDraftEvent } from '@/types' |
|
import { useGroupList } from '@/providers/group-list-context' |
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
|
import { Switch } from '@/components/ui/switch' |
|
import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' |
|
import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' |
|
import { Event, kinds } from 'nostr-tools' |
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { toast } from 'sonner' |
|
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' |
|
import EmojiPickerDialog from '../EmojiPickerDialog' |
|
import GifPicker from '../GifPicker' |
|
import MemePicker from '../MemePicker' |
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' |
|
import Mentions, { extractMentions } from './Mentions' |
|
import PollEditor from './PollEditor' |
|
import PostOptions from './PostOptions' |
|
import PostRelaySelector from './PostRelaySelector' |
|
import PostTextarea, { TPostTextareaHandle } from './PostTextarea' |
|
import { NeventPickerProvider } from './PostTextarea/Mention/NeventNaddrPickerDialog' |
|
import { MentionAndEventToolbarButtons } from './PostTextarea/Mention/MentionAndEventToolbarButtons' |
|
import Uploader from './Uploader' |
|
import HighlightEditor, { HighlightData } from './HighlightEditor' |
|
import EditOrCloneEventDialog from '../NoteOptions/EditOrCloneEventDialog' |
|
|
|
export default function PostContent({ |
|
defaultContent = '', |
|
parentEvent, |
|
close, |
|
openFrom, |
|
initialHighlightData, |
|
initialPublicMessageTo, |
|
onPublishSuccess, |
|
discussionDynamicTopics |
|
}: { |
|
defaultContent?: string |
|
parentEvent?: Event |
|
close: () => void |
|
openFrom?: string[] |
|
initialHighlightData?: HighlightData |
|
/** When set, opens in public message mode with this pubkey in the mention list. */ |
|
initialPublicMessageTo?: string |
|
/** Called after a reply/post is successfully published, before closing. */ |
|
onPublishSuccess?: () => void |
|
/** Optional hot/discussion topics (e.g. from Discussions spell) for the thread composer. */ |
|
discussionDynamicTopics?: TDiscussionDynamicTopics | null |
|
}) { |
|
const { t } = useTranslation() |
|
const { pubkey, publish, checkLogin } = useNostr() |
|
const { userGroups } = useGroupList() |
|
const { feedInfo } = useFeed() |
|
const { addReplies } = useReply() |
|
|
|
const mergePublishedReplyIntoThread = useCallback( |
|
(reply: Event, relayStatuses?: TRelayPublishStatus[]) => { |
|
if (!parentEvent) return |
|
const clean = { ...reply } as Event |
|
delete (clean as any).relayStatuses |
|
addReplies([clean]) |
|
const isQuotePost = clean.tags.some((t) => t[0] === 'q' && t[1]) |
|
noteStatsService.updateNoteStatsByEvents( |
|
[clean], |
|
undefined, |
|
isQuotePost ? undefined : { replyParentNoteId: parentEvent.id } |
|
) |
|
const rootInfo = |
|
parentEvent.kind === ExtendedKind.RSS_THREAD_ROOT |
|
? (() => { |
|
const articleUrl = getArticleUrlFromCommentITags(parentEvent) |
|
if (articleUrl) { |
|
return { |
|
type: 'I' as const, |
|
id: canonicalizeRssArticleUrl(articleUrl) |
|
} |
|
} |
|
return { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } |
|
})() |
|
: !isReplaceableEvent(parentEvent.kind) |
|
? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } |
|
: { |
|
type: 'A' as const, |
|
id: getReplaceableCoordinateFromEvent(parentEvent), |
|
eventId: parentEvent.id, |
|
pubkey: parentEvent.pubkey, |
|
relay: client.getEventHint(parentEvent.id) |
|
} |
|
const cached = discussionFeedCache.getCachedReplies(rootInfo) ?? [] |
|
const next = cached.filter((r) => r.id !== clean.id).concat([clean]) |
|
discussionFeedCache.setCachedReplies(rootInfo, next) |
|
|
|
const urls = successfulPublishRelayUrls(relayStatuses) |
|
if (!clean.id || urls.length === 0) return |
|
|
|
const delayMs = 1600 |
|
setTimeout(() => { |
|
void eventService.fetchEventWithExternalRelays(clean.id, urls).then((fresh) => { |
|
if (!fresh || fresh.id !== clean.id) return |
|
addReplies([fresh]) |
|
const merged = (discussionFeedCache.getCachedReplies(rootInfo) ?? []).filter((r) => r.id !== fresh.id) |
|
discussionFeedCache.setCachedReplies(rootInfo, [...merged, fresh]) |
|
client.addEventToCache(fresh) |
|
}) |
|
}, delayMs) |
|
}, |
|
[addReplies, parentEvent] |
|
) |
|
const [text, setText] = useState('') |
|
const textareaRef = useRef<TPostTextareaHandle>(null) |
|
const mediaUploaderBtnRef = useRef<HTMLButtonElement>(null) |
|
const [posting, setPosting] = useState(false) |
|
const [uploadProgresses, setUploadProgresses] = useState< |
|
{ file: File; progress: number; cancel: () => void }[] |
|
>([]) |
|
const [showMoreOptions, setShowMoreOptions] = useState(false) |
|
const [createCustomEventOpen, setCreateCustomEventOpen] = useState(false) |
|
const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag()) |
|
const [mentions, setMentions] = useState<string[]>([]) |
|
const [isNsfw, setIsNsfw] = useState(false) |
|
const [isPoll, setIsPoll] = useState(false) |
|
const [isPublicMessage, setIsPublicMessage] = useState(!!initialPublicMessageTo) |
|
const [extractedMentions, setExtractedMentions] = useState<string[]>( |
|
initialPublicMessageTo ? [initialPublicMessageTo] : [] |
|
) |
|
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 [isDiscussionThread, setIsDiscussionThread] = useState(false) |
|
const [threadTitle, setThreadTitle] = useState('') |
|
const [threadTopicInput, setThreadTopicInput] = useState(() => { |
|
const row = DISCUSSION_TOPICS.find((x) => x.id === 'general') |
|
return row?.label ?? 'general' |
|
}) |
|
const [threadSelectedTopic, setThreadSelectedTopic] = useState('general') |
|
const [threadSelectedGroup, setThreadSelectedGroup] = useState('') |
|
const [threadIsReadingGroup, setThreadIsReadingGroup] = useState(false) |
|
const [threadReadingAuthor, setThreadReadingAuthor] = useState('') |
|
const [threadReadingSubject, setThreadReadingSubject] = useState('') |
|
const [threadShowReadingsPanel, setThreadShowReadingsPanel] = useState(false) |
|
const [threadTopicPopoverOpen, setThreadTopicPopoverOpen] = useState(false) |
|
const [threadGroupPopoverOpen, setThreadGroupPopoverOpen] = useState(false) |
|
const [threadErrors, setThreadErrors] = useState<{ |
|
title?: string |
|
content?: string |
|
topic?: string |
|
relay?: string |
|
author?: string |
|
subject?: string |
|
group?: string |
|
}>({}) |
|
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 [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 [isCitationExternal, setIsCitationExternal] = useState(false) |
|
const [isCitationHardcopy, setIsCitationHardcopy] = useState(false) |
|
const [isCitationPrompt, setIsCitationPrompt] = useState(false) |
|
|
|
// Citation metadata fields |
|
// Internal Citation (30) |
|
const [citationInternalCTag, setCitationInternalCTag] = useState('') |
|
const [citationInternalRelayHint, setCitationInternalRelayHint] = useState('') |
|
// External Citation (31) |
|
const [citationExternalUrl, setCitationExternalUrl] = useState('') |
|
const [citationExternalOpenTimestamp, setCitationExternalOpenTimestamp] = useState('') |
|
// Hardcopy Citation (32) |
|
const [citationHardcopyPageRange, setCitationHardcopyPageRange] = useState('') |
|
const [citationHardcopyChapterTitle, setCitationHardcopyChapterTitle] = useState('') |
|
const [citationHardcopyEditor, setCitationHardcopyEditor] = useState('') |
|
const [citationHardcopyPublishedIn, setCitationHardcopyPublishedIn] = useState('') |
|
const [citationHardcopyVolume, setCitationHardcopyVolume] = useState('') |
|
const [citationHardcopyDoi, setCitationHardcopyDoi] = useState('') |
|
// Prompt Citation (33) |
|
const [citationPromptLlm, setCitationPromptLlm] = useState('') |
|
// Shared citation fields |
|
const [citationTitle, setCitationTitle] = useState('') |
|
const [citationAuthor, setCitationAuthor] = useState('') |
|
const [citationPublishedOn, setCitationPublishedOn] = useState('') |
|
const [citationPublishedBy, setCitationPublishedBy] = useState('') |
|
const [citationAccessedOn, setCitationAccessedOn] = useState('') |
|
const [citationLocation, setCitationLocation] = useState('') |
|
const [citationGeohash, setCitationGeohash] = useState('') |
|
const [citationVersion, setCitationVersion] = useState('') |
|
const [citationSummary, setCitationSummary] = useState('') |
|
|
|
const [hasPrivateRelaysAvailable, setHasPrivateRelaysAvailable] = useState(false) |
|
const [showMediaKindDialog, setShowMediaKindDialog] = useState(false) |
|
const [pendingMediaUpload, setPendingMediaUpload] = useState<{ url: string; tags: string[][]; file: File } | null>(null) |
|
const uploadedMediaFileMap = useRef<Map<string, File>>(new Map()) |
|
/** Accumulates imeta tags for kind 20 (picture) so multiple rapid uploads don’t overwrite each other. */ |
|
const pictureImetaTagsRef = useRef<string[][]>([]) |
|
/** Stable auto d-tag when the field is left empty; `{ slug, value }` resets when article subtype changes. */ |
|
const articleDTagFallbackRef = useRef<{ slug: string; value: string } | null>(null) |
|
|
|
useEffect(() => { |
|
if (articleDTag.trim()) { |
|
articleDTagFallbackRef.current = null |
|
} |
|
}, [articleDTag]) |
|
|
|
useEffect(() => { |
|
const isArticle = |
|
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent |
|
if (!isArticle) { |
|
articleDTagFallbackRef.current = null |
|
} |
|
}, [isLongFormArticle, isWikiArticle, isWikiArticleMarkdown, isPublicationContent]) |
|
|
|
useEffect(() => { |
|
if (mediaNoteKind === ExtendedKind.PICTURE && mediaImetaTags.length > 0) { |
|
pictureImetaTagsRef.current = mediaImetaTags |
|
} |
|
}, [mediaNoteKind, mediaImetaTags]) |
|
const isFirstRender = useRef(true) |
|
|
|
const allAvailableTopics = useMemo( |
|
() => buildAllAvailableTopics(discussionDynamicTopics), |
|
[discussionDynamicTopics] |
|
) |
|
|
|
const threadTopicResolved = useMemo( |
|
() => (isDiscussionThread ? resolveTopicFromInput(threadTopicInput, allAvailableTopics) : ''), |
|
[isDiscussionThread, threadTopicInput, allAvailableTopics] |
|
) |
|
|
|
const discussionPreviewExtraTags = useMemo((): string[][] | undefined => { |
|
if (!isDiscussionThread) return undefined |
|
const resolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) |
|
if (!resolved) return [] |
|
return collectDiscussionThreadTags({ |
|
processedContent: prefixNostrAddresses(text.trim()), |
|
topicForTags: resolved, |
|
title: threadTitle, |
|
selectedGroup: threadSelectedGroup, |
|
dynamicTopics: discussionDynamicTopics, |
|
isReadingGroup: threadIsReadingGroup, |
|
author: threadReadingAuthor, |
|
subject: threadReadingSubject, |
|
isNsfw |
|
}) |
|
}, [ |
|
isDiscussionThread, |
|
threadTopicInput, |
|
allAvailableTopics, |
|
text, |
|
threadTitle, |
|
threadSelectedGroup, |
|
discussionDynamicTopics, |
|
threadIsReadingGroup, |
|
threadReadingAuthor, |
|
threadReadingSubject, |
|
isNsfw |
|
]) |
|
|
|
const canPost = useMemo(() => { |
|
const discussionOk = |
|
!isDiscussionThread || |
|
!!parentEvent || |
|
(!!threadTitle.trim() && |
|
threadTitle.length <= 100 && |
|
!!threadTopicResolved && |
|
!!text.trim() && |
|
text.length <= 5000 && |
|
additionalRelayUrls.length > 0 && |
|
(!threadIsReadingGroup || (!!threadReadingAuthor.trim() && !!threadReadingSubject.trim())) && |
|
(threadTopicResolved !== 'groups' || !!threadSelectedGroup.trim())) |
|
const result = ( |
|
!!pubkey && |
|
!posting && |
|
!uploadProgresses.length && |
|
discussionOk && |
|
// For media notes, text is optional - just need media |
|
((mediaNoteKind !== null && mediaUrl) || !!text) && |
|
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) && |
|
(!isPublicMessage || extractedMentions.length > 0 || parentEvent?.kind === ExtendedKind.PUBLIC_MESSAGE) && |
|
(!isProtectedEvent || additionalRelayUrls.length > 0) && |
|
(!isHighlight || highlightData.sourceValue.trim() !== '') && |
|
// For citations, required fields must be filled |
|
(!isCitationInternal || !!citationInternalCTag.trim()) && |
|
(!isCitationExternal || (!!citationExternalUrl.trim() && !!citationAccessedOn.trim())) && |
|
(!isCitationHardcopy || !!citationAccessedOn.trim()) && |
|
(!isCitationPrompt || (!!citationPromptLlm.trim() && !!citationAccessedOn.trim())) |
|
) |
|
|
|
return result |
|
}, [ |
|
pubkey, |
|
text, |
|
posting, |
|
uploadProgresses, |
|
mediaNoteKind, |
|
mediaUrl, |
|
isPoll, |
|
pollCreateData, |
|
isPublicMessage, |
|
extractedMentions, |
|
parentEvent, |
|
isProtectedEvent, |
|
additionalRelayUrls, |
|
isHighlight, |
|
highlightData, |
|
isCitationInternal, |
|
citationInternalCTag, |
|
isCitationExternal, |
|
citationExternalUrl, |
|
citationAccessedOn, |
|
isCitationHardcopy, |
|
isCitationPrompt, |
|
citationPromptLlm, |
|
isDiscussionThread, |
|
threadTitle, |
|
threadTopicResolved, |
|
threadIsReadingGroup, |
|
threadReadingAuthor, |
|
threadReadingSubject, |
|
threadSelectedGroup |
|
]) |
|
|
|
// 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]) |
|
|
|
// 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) { |
|
if (!initialPublicMessageTo) setExtractedMentions([]) |
|
return |
|
} |
|
|
|
// Debounce the mention extraction for all posts (not just public messages) |
|
const timeoutId = setTimeout(() => { |
|
extractMentionsFromContent(text) |
|
}, 300) |
|
|
|
return () => { |
|
clearTimeout(timeoutId) |
|
} |
|
}, [text, extractMentionsFromContent, initialPublicMessageTo]) |
|
|
|
// Check for private relays availability |
|
useEffect(() => { |
|
if (!pubkey) { |
|
setHasPrivateRelaysAvailable(false) |
|
return |
|
} |
|
|
|
hasPrivateRelays(pubkey).then(setHasPrivateRelaysAvailable).catch(() => { |
|
setHasPrivateRelaysAvailable(false) |
|
}) |
|
}, [pubkey]) |
|
|
|
useEffect(() => { |
|
if (!isDiscussionThread || parentEvent) return |
|
if (!threadTitle && !text.trim()) return |
|
const h = setTimeout(() => { |
|
const tr = resolveTopicFromInput(threadTopicInput, allAvailableTopics) |
|
postEditorCache.setThreadDraft({ |
|
title: threadTitle, |
|
content: text, |
|
topic: tr || threadSelectedTopic |
|
}) |
|
}, 500) |
|
return () => clearTimeout(h) |
|
}, [ |
|
isDiscussionThread, |
|
parentEvent, |
|
threadTitle, |
|
text, |
|
threadTopicInput, |
|
threadSelectedTopic, |
|
allAvailableTopics |
|
]) |
|
|
|
// Helper function to determine the kind that will be created |
|
const getDeterminedKind = useMemo((): number => { |
|
// Public messages always take priority - even with media, they stay as PMs |
|
if (isPublicMessage) { |
|
return ExtendedKind.PUBLIC_MESSAGE |
|
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
return ExtendedKind.PUBLIC_MESSAGE |
|
} |
|
|
|
// For voice comments in replies, check mediaNoteKind even if mediaUrl is not set yet (for preview) |
|
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { |
|
return ExtendedKind.VOICE_COMMENT |
|
} else if (isDiscussionThread && !parentEvent) { |
|
return ExtendedKind.DISCUSSION |
|
} else if (mediaNoteKind !== null && mediaUrl) { |
|
return mediaNoteKind |
|
} else if (isLongFormArticle) { |
|
return kinds.LongFormArticle |
|
} else if (isWikiArticle) { |
|
return ExtendedKind.WIKI_ARTICLE |
|
} else if (isWikiArticleMarkdown) { |
|
return ExtendedKind.WIKI_ARTICLE_MARKDOWN |
|
} else if (isPublicationContent) { |
|
return ExtendedKind.PUBLICATION_CONTENT |
|
} else if (isCitationInternal) { |
|
return ExtendedKind.CITATION_INTERNAL |
|
} else if (isCitationExternal) { |
|
return ExtendedKind.CITATION_EXTERNAL |
|
} else if (isCitationHardcopy) { |
|
return ExtendedKind.CITATION_HARDCOPY |
|
} else if (isCitationPrompt) { |
|
return ExtendedKind.CITATION_PROMPT |
|
} else if (isHighlight) { |
|
return kinds.Highlights |
|
} else if (isPoll) { |
|
return ExtendedKind.POLL |
|
} else if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { |
|
return ExtendedKind.COMMENT |
|
} else { |
|
return kinds.ShortTextNote |
|
} |
|
}, [ |
|
mediaNoteKind, |
|
mediaUrl, |
|
isDiscussionThread, |
|
isLongFormArticle, |
|
isWikiArticle, |
|
isWikiArticleMarkdown, |
|
isPublicationContent, |
|
isCitationInternal, |
|
isCitationExternal, |
|
isCitationHardcopy, |
|
isCitationPrompt, |
|
isHighlight, |
|
isPublicMessage, |
|
isPoll, |
|
parentEvent |
|
]) |
|
|
|
useEffect(() => { |
|
if (isFirstRender.current) { |
|
isFirstRender.current = false |
|
const cachedSettings = postEditorCache.getPostSettingsCache({ |
|
kind: getDeterminedKind, |
|
defaultContent, |
|
parentEvent |
|
}) |
|
if (cachedSettings) { |
|
setIsNsfw(cachedSettings.isNsfw ?? false) |
|
setIsPoll(cachedSettings.isPoll ?? false) |
|
setPollCreateData( |
|
cachedSettings.pollCreateData ?? { |
|
isMultipleChoice: false, |
|
options: ['', ''], |
|
endsAt: undefined, |
|
relays: [] |
|
} |
|
) |
|
setAddClientTag(cachedSettings.addClientTag ?? storage.getAddClientTag()) |
|
} |
|
return |
|
} |
|
postEditorCache.setPostSettingsCache( |
|
{ kind: getDeterminedKind, defaultContent, parentEvent }, |
|
{ |
|
isNsfw, |
|
isPoll, |
|
pollCreateData, |
|
addClientTag |
|
} |
|
) |
|
}, [getDeterminedKind, defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) |
|
|
|
const rssReplyExtraPreviewTags = useMemo((): string[][] | undefined => { |
|
if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined |
|
const raw = |
|
parentEvent.tags.find((t) => t[0] === 'I')?.[1] ?? |
|
parentEvent.tags.find((t) => t[0] === 'i')?.[1] |
|
if (!raw) return undefined |
|
const c = canonicalizeRssArticleUrl(raw) |
|
return [['i', c], ['I', c]] |
|
}, [parentEvent]) |
|
|
|
// Shared function to create draft event - used by both preview and posting |
|
const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => { |
|
// Get expiration and quiet settings |
|
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 |
|
let shouldUseProtectedEvent = false |
|
if (parentEvent) { |
|
const isParentOP = !isReplyNoteEvent(parentEvent) |
|
const parentHasProtectedTag = isEventProtected(parentEvent) |
|
shouldUseProtectedEvent = isParentOP && parentHasProtectedTag |
|
} |
|
|
|
// Public messages - check BEFORE media notes to ensure PMs with media stay as PMs |
|
if (isPublicMessage) { |
|
return await createPublicMessageDraftEvent(cleanedText, extractedMentions, { |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays, |
|
mediaImetaTags: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined |
|
}) |
|
} else if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
// For PM replies, always create PM even if there's media |
|
return await createPublicMessageReplyDraftEvent(cleanedText, parentEvent, mentions, { |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays, |
|
mediaImetaTags: mediaNoteKind !== null && mediaUrl ? mediaImetaTags : undefined |
|
}) |
|
} |
|
|
|
if (isDiscussionThread && !parentEvent) { |
|
const processed = prefixNostrAddresses(cleanedText.trim()) |
|
const topicResolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) || threadSelectedTopic |
|
const tags = collectDiscussionThreadTags({ |
|
processedContent: processed, |
|
topicForTags: topicResolved, |
|
title: threadTitle, |
|
selectedGroup: threadSelectedGroup, |
|
dynamicTopics: discussionDynamicTopics, |
|
isReadingGroup: threadIsReadingGroup, |
|
author: threadReadingAuthor, |
|
subject: threadReadingSubject, |
|
isNsfw |
|
}) |
|
const draft: TDraftEvent = { |
|
kind: ExtendedKind.DISCUSSION, |
|
content: processed, |
|
tags, |
|
created_at: dayjs().unix() |
|
} |
|
return draft |
|
} |
|
|
|
// Check for voice comments (only for non-PM replies) |
|
if (parentEvent && mediaNoteKind === ExtendedKind.VOICE_COMMENT) { |
|
const url = mediaUrl || 'placeholder://audio' |
|
const tags = mediaImetaTags.length > 0 ? mediaImetaTags : [['imeta', `url ${url}`, 'm audio/mpeg']] |
|
return await createVoiceCommentDraftEvent( |
|
cleanedText, |
|
parentEvent, |
|
url, |
|
tags, |
|
mentions, |
|
{ |
|
addClientTag, |
|
protectedEvent: shouldUseProtectedEvent, |
|
isNsfw, |
|
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE_COMMENT), |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
} |
|
) |
|
} |
|
|
|
// Media notes |
|
if (mediaNoteKind !== null && mediaUrl) { |
|
if (mediaNoteKind === ExtendedKind.VOICE) { |
|
return await createVoiceDraftEvent( |
|
cleanedText, |
|
mediaUrl, |
|
mediaImetaTags, |
|
mentions, |
|
{ |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.VOICE), |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
} |
|
) |
|
} else if (mediaNoteKind === ExtendedKind.PICTURE) { |
|
return await createPictureDraftEvent( |
|
cleanedText, |
|
mediaImetaTags, |
|
mentions, |
|
{ |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
} |
|
) |
|
} else if (mediaNoteKind === ExtendedKind.VIDEO || mediaNoteKind === ExtendedKind.SHORT_VIDEO) { |
|
return await createVideoDraftEvent( |
|
cleanedText, |
|
mediaImetaTags, |
|
mentions, |
|
mediaNoteKind, |
|
{ |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
} |
|
) |
|
} |
|
} |
|
|
|
// Parse topics from subject field for articles |
|
const topics = articleSubject.trim() |
|
? articleSubject.split(/[,\s]+/).filter(s => s.trim()) |
|
: [] |
|
|
|
// Articles |
|
const isArticleDraft = |
|
isLongFormArticle || isWikiArticle || isWikiArticleMarkdown || isPublicationContent |
|
let effectiveArticleDTag = '' |
|
if (isArticleDraft) { |
|
const trimmedDTag = articleDTag.trim() |
|
if (trimmedDTag) { |
|
effectiveArticleDTag = trimmedDTag |
|
} else { |
|
const slug = isLongFormArticle |
|
? 'longform-article' |
|
: isWikiArticle |
|
? 'wiki-article' |
|
: isWikiArticleMarkdown |
|
? 'wiki-markdown' |
|
: 'publication-content' |
|
const prev = articleDTagFallbackRef.current |
|
if (!prev || prev.slug !== slug) { |
|
articleDTagFallbackRef.current = { |
|
slug, |
|
value: `${slug}-${Math.floor(Date.now() / 1000)}` |
|
} |
|
} |
|
effectiveArticleDTag = articleDTagFallbackRef.current!.value |
|
} |
|
} |
|
|
|
if (isLongFormArticle) { |
|
return await createLongFormArticleDraftEvent(cleanedText, mentions, { |
|
dTag: effectiveArticleDTag, |
|
title: articleTitle.trim() || undefined, |
|
summary: articleSummary.trim() || undefined, |
|
image: articleImage.trim() || undefined, |
|
topics: topics.length > 0 ? topics : undefined, |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
} else if (isWikiArticle) { |
|
return await createWikiArticleDraftEvent(cleanedText, mentions, { |
|
dTag: effectiveArticleDTag, |
|
title: articleTitle.trim() || undefined, |
|
summary: articleSummary.trim() || undefined, |
|
image: articleImage.trim() || undefined, |
|
topics: topics.length > 0 ? topics : undefined, |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
} else if (isWikiArticleMarkdown) { |
|
return await createWikiArticleMarkdownDraftEvent(cleanedText, mentions, { |
|
dTag: effectiveArticleDTag, |
|
title: articleTitle.trim() || undefined, |
|
summary: articleSummary.trim() || undefined, |
|
image: articleImage.trim() || undefined, |
|
topics: topics.length > 0 ? topics : undefined, |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
} else if (isPublicationContent) { |
|
return await createPublicationContentDraftEvent(cleanedText, mentions, { |
|
dTag: effectiveArticleDTag, |
|
title: articleTitle.trim() || undefined, |
|
summary: articleSummary.trim() || undefined, |
|
image: articleImage.trim() || undefined, |
|
topics: topics.length > 0 ? topics : undefined, |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
} |
|
|
|
// Citations |
|
if (isCitationInternal) { |
|
return createCitationInternalDraftEvent(cleanedText, { |
|
cTag: citationInternalCTag.trim(), |
|
relayHint: citationInternalRelayHint.trim() || undefined, |
|
title: citationTitle.trim() || undefined, |
|
author: citationAuthor.trim() || undefined, |
|
publishedOn: citationPublishedOn.trim() || undefined, |
|
accessedOn: citationAccessedOn.trim() || undefined, |
|
location: citationLocation.trim() || undefined, |
|
geohash: citationGeohash.trim() || undefined, |
|
summary: citationSummary.trim() || undefined |
|
}) |
|
} else if (isCitationExternal) { |
|
return createCitationExternalDraftEvent(cleanedText, { |
|
url: citationExternalUrl.trim(), |
|
accessedOn: citationAccessedOn.trim() || new Date().toISOString(), |
|
title: citationTitle.trim() || undefined, |
|
author: citationAuthor.trim() || undefined, |
|
publishedOn: citationPublishedOn.trim() || undefined, |
|
publishedBy: citationPublishedBy.trim() || undefined, |
|
version: citationVersion.trim() || undefined, |
|
location: citationLocation.trim() || undefined, |
|
geohash: citationGeohash.trim() || undefined, |
|
openTimestamp: citationExternalOpenTimestamp.trim() || undefined, |
|
summary: citationSummary.trim() || undefined |
|
}) |
|
} else if (isCitationHardcopy) { |
|
// Convert date strings to ISO 8601 format if they exist |
|
const formatDateToISO = (dateStr: string): string => { |
|
if (!dateStr || !dateStr.trim()) return '' |
|
// If already in ISO format, return as is |
|
if (dateStr.includes('T')) return dateStr |
|
// If in YYYY-MM-DD format, convert to ISO |
|
if (dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) { |
|
return new Date(dateStr + 'T00:00:00Z').toISOString() |
|
} |
|
return dateStr |
|
} |
|
|
|
const hardcopyOptions = { |
|
accessedOn: formatDateToISO(citationAccessedOn.trim()) || new Date().toISOString(), |
|
title: citationTitle.trim() || undefined, |
|
author: citationAuthor.trim() || undefined, |
|
pageRange: citationHardcopyPageRange.trim() || undefined, |
|
chapterTitle: citationHardcopyChapterTitle.trim() || undefined, |
|
editor: citationHardcopyEditor.trim() || undefined, |
|
publishedOn: citationPublishedOn.trim() ? formatDateToISO(citationPublishedOn.trim()) : undefined, |
|
publishedBy: citationPublishedBy.trim() || undefined, |
|
publishedIn: citationHardcopyPublishedIn.trim() || undefined, |
|
volume: citationHardcopyVolume.trim() || undefined, |
|
doi: citationHardcopyDoi.trim() || undefined, |
|
version: citationVersion.trim() || undefined, |
|
location: citationLocation.trim() || undefined, |
|
geohash: citationGeohash.trim() || undefined, |
|
summary: citationSummary.trim() || undefined |
|
} |
|
|
|
return createCitationHardcopyDraftEvent(cleanedText, hardcopyOptions) |
|
} else if (isCitationPrompt) { |
|
return createCitationPromptDraftEvent(cleanedText, { |
|
llm: citationPromptLlm.trim(), |
|
accessedOn: citationAccessedOn.trim() || new Date().toISOString(), |
|
version: citationVersion.trim() || undefined, |
|
summary: citationSummary.trim() || undefined, |
|
url: citationExternalUrl.trim() || undefined |
|
}) |
|
} |
|
|
|
// Highlights |
|
if (isHighlight) { |
|
return await createHighlightDraftEvent( |
|
cleanedText, |
|
highlightData.sourceType, |
|
highlightData.sourceValue, |
|
highlightData.context, |
|
undefined, |
|
{ |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
} |
|
) |
|
} |
|
|
|
|
|
// Comments and replies |
|
if (parentEvent && parentEvent.kind !== kinds.ShortTextNote) { |
|
return await createCommentDraftEvent(cleanedText, parentEvent, mentions, { |
|
addClientTag, |
|
protectedEvent: shouldUseProtectedEvent, |
|
isNsfw, |
|
addExpirationTag: addExpirationTag && isChattingKind(ExtendedKind.COMMENT), |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
} |
|
|
|
// Polls |
|
if (isPoll) { |
|
return await createPollDraftEvent(pubkey!, cleanedText, mentions, pollCreateData, { |
|
addClientTag, |
|
isNsfw, |
|
addExpirationTag: false, |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
} |
|
|
|
// Default: Short text note |
|
return await createShortTextNoteDraftEvent(cleanedText, mentions, { |
|
parentEvent, |
|
addClientTag, |
|
protectedEvent: shouldUseProtectedEvent, |
|
isNsfw, |
|
addExpirationTag: addExpirationTag && isChattingKind(kinds.ShortTextNote), |
|
expirationMonths, |
|
addQuietTag, |
|
quietDays |
|
}) |
|
}, [ |
|
parentEvent, |
|
mediaNoteKind, |
|
mediaUrl, |
|
mediaImetaTags, |
|
mentions, |
|
isDiscussionThread, |
|
threadTopicInput, |
|
allAvailableTopics, |
|
threadSelectedTopic, |
|
threadTitle, |
|
threadSelectedGroup, |
|
discussionDynamicTopics, |
|
threadIsReadingGroup, |
|
threadReadingAuthor, |
|
threadReadingSubject, |
|
isLongFormArticle, |
|
isWikiArticle, |
|
isWikiArticleMarkdown, |
|
isPublicationContent, |
|
isCitationInternal, |
|
isCitationExternal, |
|
isCitationHardcopy, |
|
isCitationPrompt, |
|
isHighlight, |
|
highlightData, |
|
isPublicMessage, |
|
extractedMentions, |
|
isPoll, |
|
pollCreateData, |
|
addClientTag, |
|
isNsfw, |
|
articleDTag, |
|
articleTitle, |
|
articleImage, |
|
articleSubject, |
|
articleSummary, |
|
pubkey, |
|
t |
|
]) |
|
|
|
// Function to generate draft event JSON for preview |
|
const getDraftEventJson = useCallback(async (): Promise<string> => { |
|
if (!pubkey) { |
|
return JSON.stringify({ error: 'Not logged in' }, null, 2) |
|
} |
|
|
|
try { |
|
// Clean tracking parameters from URLs in the post content |
|
const cleanedText = rewritePlainTextHttpUrls(text) |
|
|
|
const draftEvent = await createDraftEvent(cleanedText) |
|
return JSON.stringify(applyImwaldAttributionTags(draftEvent, { addClientTag }), null, 2) |
|
} catch (error) { |
|
return JSON.stringify({ error: error instanceof Error ? error.message : String(error) }, null, 2) |
|
} |
|
}, [text, pubkey, isDiscussionThread, createDraftEvent, addClientTag]) |
|
|
|
const post = async (e?: React.MouseEvent) => { |
|
e?.stopPropagation() |
|
checkLogin(async () => { |
|
if (!canPost) { |
|
logger.warn('Attempted to post while canPost is false') |
|
return |
|
} |
|
|
|
if (isDiscussionThread && !parentEvent) { |
|
const newErrors: typeof threadErrors = {} |
|
const topicResolved = resolveTopicFromInput(threadTopicInput, allAvailableTopics) |
|
if (!threadTitle.trim()) { |
|
newErrors.title = t('Title is required') |
|
} else if (threadTitle.length > 100) { |
|
newErrors.title = t('Title must be 100 characters or less') |
|
} |
|
if (!topicResolved) { |
|
newErrors.topic = t('Topic is required') |
|
} |
|
if (!text.trim()) { |
|
newErrors.content = t('Content is required') |
|
} else if (text.length > 5000) { |
|
newErrors.content = t('Content must be 5000 characters or less') |
|
} |
|
if (additionalRelayUrls.length === 0) { |
|
newErrors.relay = t('Please select at least one relay') |
|
} |
|
if (threadIsReadingGroup) { |
|
if (!threadReadingAuthor.trim()) { |
|
newErrors.author = t('Author is required for reading groups') |
|
} |
|
if (!threadReadingSubject.trim()) { |
|
newErrors.subject = t('Subject (book title) is required for reading groups') |
|
} |
|
} |
|
if (topicResolved === 'groups' && !threadSelectedGroup.trim()) { |
|
newErrors.group = t('Please select a group') |
|
} |
|
setThreadErrors(newErrors) |
|
if (Object.keys(newErrors).length > 0) { |
|
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 newEvent: any = null |
|
let draftEvent: any = null |
|
|
|
try { |
|
// Clean tracking parameters from URLs in the post content |
|
const cleanedText = rewritePlainTextHttpUrls(text) |
|
|
|
// Determine relay URLs for private events |
|
let privateRelayUrls: string[] = [] |
|
const isPrivateEvent = isPublicationContent || isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt |
|
if (isPrivateEvent) { |
|
// Use all private relays (outbox + cache) |
|
privateRelayUrls = await getPrivateRelayUrls(pubkey!) |
|
} |
|
|
|
// Create draft event using shared function |
|
draftEvent = await createDraftEvent(cleanedText) |
|
|
|
const publishSuccessMessage = parentEvent |
|
? t('Reply published') |
|
: isDiscussionThread && !parentEvent |
|
? t('Thread published') |
|
: t('Post published') |
|
|
|
// 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 |
|
addClientTag |
|
}) |
|
// 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: publishSuccessMessage, |
|
duration: 6000 |
|
}) |
|
} else { |
|
showSimplePublishSuccess(publishSuccessMessage) |
|
} |
|
|
|
// Full success - clean up and close |
|
postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) |
|
if (isDiscussionThread && !parentEvent) { |
|
postEditorCache.clearPostCache(discussionThreadDraftKindParams()) |
|
postEditorCache.clearThreadDraft() |
|
discussionFeedCache.clearDiscussionsListCache() |
|
} |
|
deleteDraftEventCache(draftEvent) |
|
const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined |
|
const cleanEvent = { ...newEvent } |
|
delete (cleanEvent as any).relayStatuses |
|
|
|
if (parentEvent) { |
|
mergePublishedReplyIntoThread(cleanEvent, relayStatuses) |
|
} |
|
|
|
onPublishSuccess?.() |
|
close() |
|
} catch (error) { |
|
if (error instanceof LoginRequiredError) { |
|
return |
|
} |
|
// AggregateError = "Failed to publish to any relay" is already logged in NostrProvider with relayStatuses; avoid duplicate noise |
|
if (!(error instanceof AggregateError && error.message === 'Failed to publish to any relay')) { |
|
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: show reply immediately (event already emitted by NostrProvider) |
|
if (successCount > 0) { |
|
const partialEvent = (error as any).event ?? newEvent |
|
if (parentEvent && partialEvent) { |
|
const clean = { ...partialEvent } |
|
delete (clean as any).relayStatuses |
|
mergePublishedReplyIntoThread(clean, (error as any).relayStatuses) |
|
} |
|
postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) |
|
if (isDiscussionThread && !parentEvent) { |
|
postEditorCache.clearPostCache(discussionThreadDraftKindParams()) |
|
postEditorCache.clearThreadDraft() |
|
discussionFeedCache.clearDiscussionsListCache() |
|
} |
|
if (draftEvent) deleteDraftEventCache(draftEvent) |
|
onPublishSuccess?.() |
|
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) |
|
setIsLongFormArticle(false) |
|
setIsWikiArticle(false) |
|
setIsWikiArticleMarkdown(false) |
|
setIsPublicationContent(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setIsDiscussionThread(false) |
|
setMediaNoteKind(null) |
|
setMediaUrl('') |
|
setMediaImetaTags([]) |
|
} |
|
} |
|
|
|
const handlePublicMessageToggle = () => { |
|
if (parentEvent) return |
|
|
|
setIsPublicMessage((prev) => !prev) |
|
if (!isPublicMessage) { |
|
// When enabling public message mode, clear other modes |
|
setIsPoll(false) |
|
setIsHighlight(false) |
|
setIsLongFormArticle(false) |
|
setIsWikiArticle(false) |
|
setIsWikiArticleMarkdown(false) |
|
setIsPublicationContent(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setIsDiscussionThread(false) |
|
setMediaNoteKind(null) |
|
setMediaUrl('') |
|
setMediaImetaTags([]) |
|
} |
|
} |
|
|
|
const handlePlainNoteMode = () => { |
|
if (parentEvent) return |
|
setIsPoll(false) |
|
setIsPublicMessage(false) |
|
setIsHighlight(false) |
|
setIsLongFormArticle(false) |
|
setIsWikiArticle(false) |
|
setIsWikiArticleMarkdown(false) |
|
setIsPublicationContent(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setIsDiscussionThread(false) |
|
setMediaNoteKind(null) |
|
setMediaUrl('') |
|
setMediaImetaTags([]) |
|
pictureImetaTagsRef.current = [] |
|
uploadedMediaFileMap.current.clear() |
|
} |
|
|
|
const inferKindFromEditorMediaUrl = (url: string): number | null => { |
|
const path = url.split(/[?#]/)[0].toLowerCase() |
|
if (/\.(jpg|jpeg|png|gif|webp|heic|avif|apng)$/i.test(path)) return ExtendedKind.PICTURE |
|
if (/\.(mp3|m4a|mka|ogg|opus|wav|aac|flac)$/i.test(path)) return ExtendedKind.VOICE |
|
if (/\.(mp4|webm|mov|mkv|m4v|ogv|avi|mpeg|mpg)$/i.test(path)) return ExtendedKind.SHORT_VIDEO |
|
return null |
|
} |
|
|
|
const mimeFromUrlPathForKind = (url: string, kind: number): string => { |
|
const path = url.split(/[?#]/)[0].toLowerCase() |
|
if (kind === ExtendedKind.PICTURE) { |
|
if (path.endsWith('.png')) return 'image/png' |
|
if (path.endsWith('.webp')) return 'image/webp' |
|
if (path.endsWith('.gif')) return 'image/gif' |
|
return 'image/jpeg' |
|
} |
|
if (kind === ExtendedKind.VOICE) { |
|
if (path.endsWith('.mka')) return 'audio/x-matroska' |
|
if (path.endsWith('.ogg')) return 'audio/ogg' |
|
if (path.endsWith('.webm')) return 'audio/webm' |
|
return 'audio/mpeg' |
|
} |
|
if (path.endsWith('.mkv')) return 'video/x-matroska' |
|
if (path.endsWith('.webm')) return 'video/webm' |
|
return 'video/mp4' |
|
} |
|
|
|
const textLooksLikeImetaWithUrl = (s: string): boolean => |
|
/\bimeta\b[\s\S]{0,400}?\burl\s+https?:\/\//i.test(s) |
|
|
|
const firstHttpUrlInNoteText = (s: string): string | undefined => { |
|
const m = s.match(/https?:\/\/[^\s<>\])}'"]+/) |
|
return m?.[0] |
|
} |
|
|
|
const canUseMediaKindFromUrlButton = useMemo(() => { |
|
if (parentEvent || isDiscussionThread || isPublicMessage) return false |
|
if (mediaNoteKind !== null && mediaUrl) return false |
|
if (mediaImetaTags.length > 0) return true |
|
if (mediaUrl) return true |
|
if (textLooksLikeImetaWithUrl(text)) return true |
|
const u = firstHttpUrlInNoteText(text) |
|
return !!(u && inferKindFromEditorMediaUrl(u) !== null) |
|
}, [ |
|
parentEvent, |
|
isDiscussionThread, |
|
isPublicMessage, |
|
mediaNoteKind, |
|
mediaUrl, |
|
mediaImetaTags, |
|
text |
|
]) |
|
|
|
/** When the editor already contains a media URL (e.g. after drop/paste) but kind stayed 1. */ |
|
const handleUseMediaNoteKindFromUrl = () => { |
|
if (parentEvent || isDiscussionThread || isPublicMessage) return |
|
if (mediaNoteKind !== null && mediaUrl) { |
|
toast.info(t('Already publishing as a media note')) |
|
return |
|
} |
|
const raw = textareaRef.current?.getText() ?? text |
|
const m = raw.match(/https?:\/\/[^\s<>\])}'"]+/) |
|
const found = m?.[0] |
|
if (!found) { |
|
toast.info(t('No media URL in note — upload or paste a link first')) |
|
return |
|
} |
|
const kind = inferKindFromEditorMediaUrl(found) |
|
if (kind === null) { |
|
toast.info(t('Cannot infer media type from URL — use Note type → Media Note to upload')) |
|
return |
|
} |
|
setIsPoll(false) |
|
setIsHighlight(false) |
|
setIsLongFormArticle(false) |
|
setIsWikiArticle(false) |
|
setIsWikiArticleMarkdown(false) |
|
setIsPublicationContent(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setIsDiscussionThread(false) |
|
setMediaUrl(found) |
|
setMediaNoteKind(kind) |
|
const mime = mimeFromUrlPathForKind(found, kind) |
|
setMediaImetaTags([['imeta', `url ${found}`, `m ${mime}`]]) |
|
} |
|
|
|
const isPlainShortNoteToolbar = useMemo( |
|
() => |
|
!parentEvent && |
|
!isPoll && |
|
!isPublicMessage && |
|
!isHighlight && |
|
!isLongFormArticle && |
|
!isWikiArticle && |
|
!isWikiArticleMarkdown && |
|
!isPublicationContent && |
|
!isCitationInternal && |
|
!isCitationExternal && |
|
!isCitationHardcopy && |
|
!isCitationPrompt && |
|
!isDiscussionThread && |
|
mediaNoteKind === null && |
|
!mediaUrl, |
|
[ |
|
parentEvent, |
|
isPoll, |
|
isPublicMessage, |
|
isHighlight, |
|
isLongFormArticle, |
|
isWikiArticle, |
|
isWikiArticleMarkdown, |
|
isPublicationContent, |
|
isCitationInternal, |
|
isCitationExternal, |
|
isCitationHardcopy, |
|
isCitationPrompt, |
|
isDiscussionThread, |
|
mediaNoteKind, |
|
mediaUrl |
|
] |
|
) |
|
|
|
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) |
|
setIsLongFormArticle(false) |
|
setIsWikiArticle(false) |
|
setIsWikiArticleMarkdown(false) |
|
setIsPublicationContent(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setIsDiscussionThread(false) |
|
setMediaNoteKind(null) |
|
setMediaUrl('') |
|
setMediaImetaTags([]) |
|
setAddClientTag(true) |
|
} |
|
} |
|
|
|
const handleDiscussionThreadToggle = () => { |
|
if (parentEvent) return |
|
if (!isDiscussionThread) { |
|
setIsPoll(false) |
|
setIsPublicMessage(false) |
|
setIsHighlight(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setMediaNoteKind(null) |
|
setMediaUrl('') |
|
setMediaImetaTags([]) |
|
const draft = postEditorCache.getThreadDraft() |
|
if (draft) { |
|
setThreadTitle(draft.title) |
|
setText(draft.content) |
|
setThreadSelectedTopic(draft.topic) |
|
const predefined = DISCUSSION_TOPICS.find((x) => x.id === draft.topic) |
|
const dyn = discussionDynamicTopics?.allTopics.find((x) => x.id === draft.topic) |
|
setThreadTopicInput(predefined?.label ?? dyn?.label ?? draft.topic) |
|
} else { |
|
setThreadTitle('') |
|
setThreadSelectedTopic('general') |
|
const row = DISCUSSION_TOPICS.find((x) => x.id === 'general') |
|
setThreadTopicInput(row?.label ?? 'general') |
|
} |
|
setThreadErrors({}) |
|
setIsDiscussionThread(true) |
|
} else { |
|
setIsDiscussionThread(false) |
|
setThreadErrors({}) |
|
} |
|
} |
|
|
|
const handleUploadStart = (file: File, cancel: () => void) => { |
|
setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }]) |
|
// Track file for media upload |
|
if (fileLooksLikeUploadableMedia(file)) { |
|
const mapKey = `${file.name}-${file.size}-${file.lastModified}` |
|
uploadedMediaFileMap.current.set(mapKey, file) |
|
|
|
// For replies and PMs, if it's an audio file, set mediaNoteKind immediately for preview |
|
if (parentEvent || isPublicMessage) { |
|
const fileType = file.type |
|
const fileName = file.name.toLowerCase() |
|
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4 |
|
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg' |
|
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) |
|
// For replies/PMs, webm/ogg/mp3/m4a files should be treated as audio since the microphone button only accepts audio/* |
|
// Even if the MIME type is incorrect, if it came through the audio uploader, it's audio |
|
const isWebmFile = /\.webm$/i.test(fileName) |
|
const isOggFile = /\.ogg$/i.test(fileName) |
|
const isMp3File = /\.mp3$/i.test(fileName) |
|
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this) |
|
const isM4aFile = /\.m4a$/i.test(fileName) |
|
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime |
|
|
|
// For replies/PMs, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files) |
|
// m4a files are always audio, even if MIME type is wrong |
|
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File |
|
|
|
if (isAudio) { |
|
// For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags |
|
if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
// Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags |
|
} else if (parentEvent) { |
|
setMediaNoteKind(ExtendedKind.VOICE_COMMENT) |
|
} else if (isPublicMessage) { |
|
setMediaNoteKind(ExtendedKind.VOICE) |
|
} |
|
// Note: URL will be inserted when upload completes in handleMediaUploadSuccess |
|
} |
|
} else if (!isDiscussionThread) { |
|
// For new posts, detect the kind from the file (async) |
|
getMediaKindFromFile(file, false) |
|
.then((kind) => setMediaNoteKind(kind)) |
|
.catch((error) => { |
|
logger.error('Error detecting media kind in handleUploadStart', { error, file: file.name }) |
|
}) |
|
} |
|
} |
|
} |
|
|
|
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 |
|
} |
|
|
|
// Helper function to check if a file could be either audio or video |
|
const isAmbiguousMediaFile = (file: File): boolean => { |
|
if (parentEvent) { |
|
// For replies, we don't show the dialog - audio button only accepts audio/* |
|
return false |
|
} |
|
|
|
const fileType = file.type |
|
const fileName = file.name.toLowerCase() |
|
|
|
// Check if it's a webm or mp4 file that could be either audio or video |
|
const isWebm = /\.webm$/i.test(fileName) |
|
const isMp4 = /\.mp4$/i.test(fileName) |
|
|
|
if (isWebm || isMp4) { |
|
// If MIME type is missing, it's ambiguous |
|
if (!fileType || fileType === 'application/octet-stream') { |
|
return true |
|
} |
|
|
|
const isAudioMime = fileType.startsWith('audio/') |
|
const isVideoMime = fileType.startsWith('video/') |
|
|
|
// If MIME type doesn't clearly indicate one or the other, it's ambiguous |
|
// Some browsers report video/webm for audio-only webm files, so we show the dialog |
|
// to let the user choose |
|
if (isWebm) { |
|
// WebM files are often misreported, so show dialog |
|
return true |
|
} |
|
|
|
if (isMp4) { |
|
// MP4 files can be audio or video - if MIME type is video/mp4 but could be audio, |
|
// or if it's unclear, show dialog |
|
// Only show if MIME type suggests it could be either |
|
if (!isAudioMime && !isVideoMime) { |
|
return true |
|
} |
|
// If it's video/mp4, it could still be audio-only, so show dialog |
|
if (isVideoMime) { |
|
return true |
|
} |
|
} |
|
} |
|
|
|
return false |
|
} |
|
|
|
const handleMediaKindSelection = (selectedKind: number) => { |
|
if (!pendingMediaUpload) return |
|
|
|
const { url, tags, file } = pendingMediaUpload |
|
setShowMediaKindDialog(false) |
|
setPendingMediaUpload(null) |
|
|
|
// Process the upload with the selected kind |
|
processMediaUpload(url, tags, file, selectedKind) |
|
} |
|
|
|
const processMediaUpload = async (url: string, tags: string[][], uploadingFile: File, selectedKind?: number) => { |
|
try { |
|
let kind: number |
|
|
|
if (selectedKind !== undefined) { |
|
// Use the selected kind |
|
kind = selectedKind |
|
} else { |
|
// Auto-detect the kind |
|
kind = await getMediaKindFromFile(uploadingFile, false) |
|
} |
|
|
|
setMediaNoteKind(kind) |
|
|
|
// For picture notes, support multiple images by accumulating imeta tags |
|
if (kind === ExtendedKind.PICTURE) { |
|
// Get imeta tag from media upload service |
|
const imetaTag = mediaUpload.getImetaTagByUrl(url) |
|
let newImetaTag: string[] |
|
if (imetaTag) { |
|
newImetaTag = imetaTag |
|
} else if (tags && tags.length > 0 && tags[0]) { |
|
newImetaTag = tags[0] |
|
} else { |
|
// Create a basic imeta tag if none exists |
|
newImetaTag = ['imeta', `url ${url}`] |
|
if (uploadingFile.type) { |
|
newImetaTag.push(`m ${uploadingFile.type}`) |
|
} |
|
} |
|
|
|
// Accumulate multiple imeta tags for picture notes (use ref so rapid multi-upload doesn’t lose tags) |
|
const urlExists = pictureImetaTagsRef.current.some((tag) => { |
|
const urlItem = tag.find((item) => item.startsWith('url ')) |
|
return urlItem && urlItem.slice(4).trim() === url |
|
}) |
|
if (!urlExists) { |
|
pictureImetaTagsRef.current = [...pictureImetaTagsRef.current, newImetaTag] |
|
setMediaImetaTags([...pictureImetaTagsRef.current]) |
|
} |
|
|
|
// Set the first URL as the primary mediaUrl (for backwards compatibility) |
|
if (!mediaUrl) { |
|
setMediaUrl(url) |
|
} |
|
|
|
// Insert the URL into the editor content so it shows in the edit pane |
|
// Use setTimeout to ensure the state has updated and editor is ready |
|
setTimeout(() => { |
|
if (textareaRef.current) { |
|
// Check the actual editor content, not the state variable (which might be stale) |
|
const currentText = textareaRef.current.getText() |
|
if (!currentText.includes(url)) { |
|
textareaRef.current.appendText(url, true) |
|
} |
|
} |
|
}, 100) |
|
} else { |
|
// For non-picture media, replace the existing tags (single media) |
|
pictureImetaTagsRef.current = [] |
|
setMediaUrl(url) |
|
const imetaTag = mediaUpload.getImetaTagByUrl(url) |
|
if (imetaTag) { |
|
setMediaImetaTags([imetaTag]) |
|
} else if (tags && tags.length > 0) { |
|
setMediaImetaTags(tags) |
|
} else { |
|
const basicImetaTag: string[] = ['imeta', `url ${url}`] |
|
// Update MIME type based on selected kind |
|
let mimeType = uploadingFile.type |
|
if (selectedKind === ExtendedKind.VOICE || selectedKind === ExtendedKind.VOICE_COMMENT) { |
|
// Ensure audio MIME type |
|
const fileName = uploadingFile.name.toLowerCase() |
|
if (/\.webm$/i.test(fileName)) { |
|
mimeType = 'audio/webm' |
|
} else if (/\.mka$/i.test(fileName)) { |
|
mimeType = 'audio/x-matroska' |
|
} else if (/\.mp4$/i.test(fileName)) { |
|
mimeType = 'audio/mp4' |
|
} |
|
} else if (selectedKind === ExtendedKind.VIDEO || selectedKind === ExtendedKind.SHORT_VIDEO) { |
|
// Ensure video MIME type |
|
const fileName = uploadingFile.name.toLowerCase() |
|
if (/\.webm$/i.test(fileName)) { |
|
mimeType = 'video/webm' |
|
} else if (/\.mkv$/i.test(fileName)) { |
|
mimeType = 'video/x-matroska' |
|
} else if (/\.mp4$/i.test(fileName)) { |
|
mimeType = 'video/mp4' |
|
} |
|
} |
|
if (mimeType) { |
|
basicImetaTag.push(`m ${mimeType}`) |
|
} |
|
setMediaImetaTags([basicImetaTag]) |
|
} |
|
|
|
// Insert the URL into the editor content so it shows in the edit pane |
|
// Use setTimeout to ensure the state has updated and editor is ready |
|
setTimeout(() => { |
|
if (textareaRef.current) { |
|
// Check the actual editor content, not the state variable (which might be stale) |
|
const currentText = textareaRef.current.getText() |
|
if (!currentText.includes(url)) { |
|
textareaRef.current.appendText(url, true) |
|
} |
|
} |
|
}, 100) |
|
} |
|
} catch (error) { |
|
logger.error('Error processing media upload', { error, file: uploadingFile.name }) |
|
// Fallback to picture if processing fails |
|
setMediaNoteKind(ExtendedKind.PICTURE) |
|
const imetaTag = mediaUpload.getImetaTagByUrl(url) |
|
const tagToAdd = imetaTag ?? (() => { |
|
const basic: string[] = ['imeta', `url ${url}`] |
|
if (uploadingFile.type) basic.push(`m ${uploadingFile.type}`) |
|
return basic |
|
})() |
|
pictureImetaTagsRef.current = [...pictureImetaTagsRef.current, tagToAdd] |
|
setMediaImetaTags([...pictureImetaTagsRef.current]) |
|
if (!mediaUrl) { |
|
setMediaUrl(url) |
|
} |
|
} |
|
} |
|
|
|
const handleMediaUploadSuccess = async ({ |
|
url, |
|
tags, |
|
file: fileFromCallback |
|
}: { |
|
url: string |
|
tags: string[][] |
|
file?: File |
|
}) => { |
|
try { |
|
let uploadingFile: File | undefined = fileFromCallback |
|
if (!uploadingFile) { |
|
for (const [, file] of uploadedMediaFileMap.current.entries()) { |
|
uploadingFile = file |
|
break |
|
} |
|
} |
|
if (!uploadingFile) { |
|
const progressItem = uploadProgresses.find((p) => p.file) |
|
uploadingFile = progressItem?.file |
|
} |
|
if (!uploadingFile) { |
|
logger.warn('Media upload succeeded but file not found') |
|
return |
|
} |
|
|
|
if (isDiscussionThread && !parentEvent) { |
|
setTimeout(() => { |
|
const ed = textareaRef.current |
|
if (ed && !ed.getText().includes(url)) { |
|
ed.appendText(url, true) |
|
} |
|
}, 100) |
|
uploadedMediaFileMap.current.delete(`${uploadingFile.name}-${uploadingFile.size}-${uploadingFile.lastModified}`) |
|
handleUploadEnd(uploadingFile) |
|
return |
|
} |
|
|
|
// Determine media kind from file |
|
// For replies, only audio comments are supported (kind 1244) |
|
// For new PMs, audio messages are supported (kind 1222) |
|
// For new posts, all media types are supported |
|
if (parentEvent || isPublicMessage) { |
|
// For replies and PMs, only allow audio |
|
const fileType = uploadingFile.type |
|
const fileName = uploadingFile.name.toLowerCase() |
|
// Check for audio files - including mp4/m4a/webm/ogg/mp3 which can be audio |
|
// mp4/m4a/webm/ogg/mp3 files can be audio if MIME type is audio/* |
|
// For replies/PMs, webm/ogg/mp3 files should be treated as audio since the microphone button only accepts audio/* |
|
// Mobile browsers may report m4a files as audio/m4a, audio/mp4, audio/x-m4a, or even video/mp4 |
|
const isAudioMime = fileType.startsWith('audio/') || fileType === 'audio/mp4' || fileType === 'audio/x-m4a' || fileType === 'audio/m4a' || fileType === 'audio/webm' || fileType === 'audio/mpeg' |
|
const isAudioExt = /\.(mp3|m4a|mka|ogg|wav|opus|aac|flac|mpeg|mp4)$/i.test(fileName) |
|
// m4a files are always audio, even if MIME type is video/mp4 (mobile browsers sometimes report this) |
|
const isM4aFile = /\.m4a$/i.test(fileName) |
|
const isMp4Audio = /\.mp4$/i.test(fileName) && isAudioMime |
|
const isWebmFile = /\.webm$/i.test(fileName) |
|
const isOggFile = /\.ogg$/i.test(fileName) |
|
const isMp3File = /\.mp3$/i.test(fileName) |
|
|
|
// For replies/PMs, treat webm/ogg/mp3/m4a as audio (since accept="audio/*" should filter out video files) |
|
// m4a files are always audio, even if MIME type is wrong |
|
const isAudio = isAudioMime || isAudioExt || isM4aFile || isMp4Audio || isWebmFile || isOggFile || isMp3File |
|
|
|
if (isAudio) { |
|
// For PM replies, don't set mediaNoteKind - let PM reply handle it with imeta tags |
|
if (parentEvent && parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
// Don't set mediaNoteKind - PM replies stay as kind 24 with imeta tags |
|
// Just set the URL and imeta tags |
|
} else if (parentEvent) { |
|
// For regular replies, always create voice comments (kind 1244), regardless of duration |
|
setMediaNoteKind(ExtendedKind.VOICE_COMMENT) |
|
} else if (isPublicMessage) { |
|
// For new PMs, create voice notes (kind 1222) |
|
setMediaNoteKind(ExtendedKind.VOICE) |
|
} |
|
setMediaUrl(url) |
|
// Get imeta tag from media upload service |
|
const imetaTag = mediaUpload.getImetaTagByUrl(url) |
|
if (imetaTag) { |
|
setMediaImetaTags([imetaTag]) |
|
} else if (tags && tags.length > 0) { |
|
setMediaImetaTags(tags) |
|
} else { |
|
const basicImetaTag: string[] = ['imeta', `url ${url}`] |
|
// For webm/ogg/mp3/m4a files uploaded via microphone, ensure MIME type is set to audio/* |
|
// even if the browser reports video/webm or video/mp4 (mobile browsers sometimes do this) |
|
let mimeType = uploadingFile.type |
|
const fileName = uploadingFile.name.toLowerCase() |
|
if (/\.m4a$/i.test(fileName)) { |
|
// m4a files are always audio, use audio/mp4 or audio/x-m4a |
|
mimeType = 'audio/mp4' |
|
} else if (/\.mka$/i.test(fileName)) { |
|
mimeType = 'audio/x-matroska' |
|
} else if (/\.webm$/i.test(fileName) && !mimeType.startsWith('audio/')) { |
|
mimeType = 'audio/webm' |
|
} else if (/\.ogg$/i.test(fileName) && !mimeType.startsWith('audio/')) { |
|
mimeType = 'audio/ogg' |
|
} else if (/\.mp3$/i.test(fileName) && !mimeType.startsWith('audio/')) { |
|
mimeType = 'audio/mpeg' |
|
} |
|
if (mimeType) { |
|
basicImetaTag.push(`m ${mimeType}`) |
|
} |
|
setMediaImetaTags([basicImetaTag]) |
|
} |
|
// Insert the URL into the editor content so it shows in the edit pane |
|
// Use setTimeout to ensure the state has updated and editor is ready |
|
setTimeout(() => { |
|
if (textareaRef.current) { |
|
// Check if URL is already in the text |
|
const currentText = text || '' |
|
if (!currentText.includes(url)) { |
|
textareaRef.current.appendText(url, true) |
|
} |
|
} |
|
}, 100) |
|
} else { |
|
// Non-audio media in replies/PMs - don't set mediaNoteKind, will be handled as regular comment/PM |
|
// 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/PMs |
|
} |
|
} else { |
|
// For new posts, check if file is ambiguous (could be audio or video) |
|
if (isAmbiguousMediaFile(uploadingFile)) { |
|
// Show dialog to let user choose |
|
setPendingMediaUpload({ url, tags, file: uploadingFile }) |
|
setShowMediaKindDialog(true) |
|
return |
|
} |
|
|
|
// Not ambiguous, auto-detect and process |
|
await processMediaUpload(url, tags, uploadingFile) |
|
} |
|
} catch (error) { |
|
logger.error('Error in handleMediaUploadSuccess', { error }) |
|
// Don't throw - just log the error so the upload doesn't fail completely |
|
} |
|
|
|
// 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) |
|
setIsDiscussionThread(false) |
|
|
|
// Clear uploaded file from map and picture accumulation ref |
|
uploadedMediaFileMap.current.clear() |
|
pictureImetaTagsRef.current = [] |
|
} |
|
|
|
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) |
|
setIsDiscussionThread(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') => { |
|
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) |
|
setIsDiscussionThread(false) |
|
|
|
// Set default accessedOn if not already set |
|
if (!citationAccessedOn && (type === 'external' || type === 'hardcopy' || type === 'prompt')) { |
|
setCitationAccessedOn(new Date().toISOString().split('T')[0]) // ISO date format YYYY-MM-DD |
|
} |
|
} |
|
|
|
const handleClear = () => { |
|
const wasDiscussion = isDiscussionThread |
|
// Clear the post editor cache |
|
postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) |
|
|
|
// Clear the editor content |
|
textareaRef.current?.clear() |
|
|
|
// Reset all state |
|
setText('') |
|
setMediaNoteKind(null) |
|
setMediaUrl('') |
|
setMediaImetaTags([]) |
|
setMentions([]) |
|
setExtractedMentions([]) |
|
setIsPoll(false) |
|
setIsPublicMessage(false) |
|
setIsHighlight(false) |
|
setIsLongFormArticle(false) |
|
setIsWikiArticle(false) |
|
setIsWikiArticleMarkdown(false) |
|
setIsPublicationContent(false) |
|
setIsCitationInternal(false) |
|
setIsCitationExternal(false) |
|
setIsCitationHardcopy(false) |
|
setIsCitationPrompt(false) |
|
setIsDiscussionThread(false) |
|
setThreadTitle('') |
|
setThreadSelectedTopic('general') |
|
const gRow = DISCUSSION_TOPICS.find((x) => x.id === 'general') |
|
setThreadTopicInput(gRow?.label ?? 'general') |
|
setThreadSelectedGroup('') |
|
setThreadIsReadingGroup(false) |
|
setThreadReadingAuthor('') |
|
setThreadReadingSubject('') |
|
setThreadShowReadingsPanel(false) |
|
setThreadErrors({}) |
|
if (wasDiscussion) { |
|
postEditorCache.clearThreadDraft() |
|
postEditorCache.clearPostCache(discussionThreadDraftKindParams()) |
|
} |
|
// Clear citation fields |
|
setCitationInternalCTag('') |
|
setCitationInternalRelayHint('') |
|
setCitationExternalUrl('') |
|
setCitationExternalOpenTimestamp('') |
|
setCitationHardcopyPageRange('') |
|
setCitationHardcopyChapterTitle('') |
|
setCitationHardcopyEditor('') |
|
setCitationHardcopyPublishedIn('') |
|
setCitationHardcopyVolume('') |
|
setCitationHardcopyDoi('') |
|
setCitationTitle('') |
|
setCitationAuthor('') |
|
setCitationPublishedOn('') |
|
setCitationPublishedBy('') |
|
setCitationAccessedOn('') |
|
setCitationLocation('') |
|
setCitationGeohash('') |
|
setCitationVersion('') |
|
setCitationSummary('') |
|
setCitationPromptLlm('') |
|
setPollCreateData({ |
|
isMultipleChoice: false, |
|
options: ['', ''], |
|
endsAt: undefined, |
|
relays: [] |
|
}) |
|
setHighlightData({ |
|
sourceType: 'nostr', |
|
sourceValue: '' |
|
}) |
|
uploadedMediaFileMap.current.clear() |
|
pictureImetaTagsRef.current = [] |
|
setUploadProgresses([]) |
|
} |
|
|
|
return ( |
|
<div className="space-y-2 min-w-0"> |
|
{/* Dynamic Title based on mode */} |
|
<div className="text-lg font-semibold"> |
|
{(() => { |
|
const determinedKind = getDeterminedKind |
|
if (parentEvent) { |
|
if (parentEvent.kind === ExtendedKind.PUBLIC_MESSAGE) { |
|
return t('Reply to Public Message') |
|
} else if (determinedKind === ExtendedKind.VOICE_COMMENT) { |
|
return t('Voice Comment') |
|
} else { |
|
return t('Reply to') |
|
} |
|
} else if (determinedKind === ExtendedKind.VOICE) { |
|
return t('Voice Note') |
|
} else if (determinedKind === ExtendedKind.PICTURE) { |
|
return t('Picture Note') |
|
} else if (determinedKind === ExtendedKind.VIDEO) { |
|
return t('Video Note') |
|
} else if (determinedKind === ExtendedKind.SHORT_VIDEO) { |
|
return t('Short Video Note') |
|
} else if (determinedKind === ExtendedKind.POLL) { |
|
return t('New Poll') |
|
} else if (determinedKind === ExtendedKind.PUBLIC_MESSAGE) { |
|
return t('New Public Message') |
|
} else if (determinedKind === kinds.Highlights) { |
|
return t('New Highlight') |
|
} else if (determinedKind === ExtendedKind.DISCUSSION) { |
|
return t('New Discussion') |
|
} else if (determinedKind === kinds.LongFormArticle) { |
|
return t('New Long-form Article') |
|
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE) { |
|
return t('New Wiki Article') |
|
} else if (determinedKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { |
|
return t('New Wiki Article (Markdown)') |
|
} else if (determinedKind === ExtendedKind.PUBLICATION_CONTENT) { |
|
return t('Take a note') |
|
} else if (determinedKind === ExtendedKind.CITATION_INTERNAL) { |
|
return t('New Internal Citation') |
|
} else if (determinedKind === ExtendedKind.CITATION_EXTERNAL) { |
|
return t('New External Citation') |
|
} else if (determinedKind === ExtendedKind.CITATION_HARDCOPY) { |
|
return t('New Hardcopy Citation') |
|
} else if (determinedKind === ExtendedKind.CITATION_PROMPT) { |
|
return t('New Prompt Citation') |
|
} else { |
|
return 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> |
|
)} |
|
|
|
{isDiscussionThread && !parentEvent && ( |
|
<div className="shrink-0 space-y-3 rounded-lg border bg-muted/30 p-4"> |
|
<div className="space-y-2"> |
|
<Label htmlFor="discussion-topic-input" className="text-sm font-medium"> |
|
{t('Topic')} <span className="text-destructive">*</span> |
|
</Label> |
|
<div className="flex min-w-0 gap-2"> |
|
<Input |
|
id="discussion-topic-input" |
|
value={threadTopicInput} |
|
onChange={(e) => setThreadTopicInput(e.target.value)} |
|
onBlur={() => { |
|
const r = resolveTopicFromInput(threadTopicInput, allAvailableTopics) |
|
if (r) { |
|
setThreadSelectedTopic(r) |
|
setThreadTopicInput(displayTopicLabel(r, allAvailableTopics)) |
|
} |
|
}} |
|
placeholder={t('Type a topic or pick from the list')} |
|
autoComplete="off" |
|
className={cn('min-w-0 flex-1 bg-background', threadErrors.topic && 'border-destructive')} |
|
/> |
|
<Popover open={threadTopicPopoverOpen} onOpenChange={setThreadTopicPopoverOpen}> |
|
<PopoverTrigger asChild> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
size="icon" |
|
className="h-9 w-9 shrink-0" |
|
title={t('Suggested topics')} |
|
aria-expanded={threadTopicPopoverOpen} |
|
> |
|
<ChevronDown className="h-4 w-4 opacity-70" /> |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent className="z-[10000] w-72 p-2" align="end" side="bottom" sideOffset={4}> |
|
<p className="text-muted-foreground mb-2 px-1 text-xs font-medium">{t('Suggested topics')}</p> |
|
<div className="max-h-60 overflow-y-auto"> |
|
{allAvailableTopics.map((topic, index) => { |
|
const Icon = topic.icon |
|
return ( |
|
<div |
|
key={`topic-${index}-${topic.id}`} |
|
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent" |
|
onClick={() => { |
|
setThreadSelectedTopic(topic.id) |
|
setThreadTopicInput(topic.label) |
|
setThreadTopicPopoverOpen(false) |
|
}} |
|
> |
|
<Check |
|
className={`mr-2 h-4 w-4 ${threadTopicResolved === topic.id ? 'opacity-100' : 'opacity-0'}`} |
|
/> |
|
<Icon className="mr-2 h-4 w-4 shrink-0" /> |
|
<span className="min-w-0 truncate text-sm">{topic.label}</span> |
|
</div> |
|
) |
|
})} |
|
</div> |
|
</PopoverContent> |
|
</Popover> |
|
</div> |
|
{threadErrors.topic && <p className="text-sm text-destructive">{threadErrors.topic}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{t( |
|
'Choose a suggested topic or type your own. It becomes a normalized tag (e.g. my-topic).' |
|
)} |
|
</p> |
|
</div> |
|
|
|
{threadTopicResolved === 'groups' && ( |
|
<div className="space-y-2"> |
|
<Label htmlFor="discussion-group" className="text-sm font-medium"> |
|
{t('Select Group')} |
|
</Label> |
|
<Popover open={threadGroupPopoverOpen} onOpenChange={setThreadGroupPopoverOpen}> |
|
<PopoverTrigger asChild> |
|
<Button |
|
variant="outline" |
|
role="combobox" |
|
aria-expanded={threadGroupPopoverOpen} |
|
title={t('Select group...')} |
|
className="h-9 w-full justify-between bg-background font-normal" |
|
> |
|
{threadSelectedGroup ? threadSelectedGroup : t('Select group...')} |
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
|
</Button> |
|
</PopoverTrigger> |
|
<PopoverContent |
|
className="z-[10000] w-[--radix-popover-trigger-width] p-2" |
|
align="start" |
|
side="bottom" |
|
sideOffset={4} |
|
> |
|
<div className="max-h-60 overflow-y-auto"> |
|
{userGroups.length === 0 ? ( |
|
<div className="p-2 text-center text-sm text-muted-foreground"> |
|
{t('No groups available. Join some groups first.')} |
|
</div> |
|
) : ( |
|
userGroups.map((groupId) => ( |
|
<div |
|
key={groupId} |
|
className="flex cursor-pointer items-center rounded p-2 hover:bg-accent" |
|
onClick={() => { |
|
setThreadSelectedGroup(groupId) |
|
setThreadGroupPopoverOpen(false) |
|
}} |
|
> |
|
<Check |
|
className={`mr-2 h-4 w-4 ${threadSelectedGroup === groupId ? 'opacity-100' : 'opacity-0'}`} |
|
/> |
|
<Users className="mr-2 h-4 w-4" /> |
|
{groupId} |
|
</div> |
|
)) |
|
)} |
|
</div> |
|
</PopoverContent> |
|
</Popover> |
|
{threadErrors.group && <p className="text-sm text-destructive">{threadErrors.group}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{t('Select the group where you want to create this discussion.')} |
|
</p> |
|
</div> |
|
)} |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="discussion-thread-title" className="text-sm font-medium"> |
|
{t('Title')} <span className="text-destructive">*</span> |
|
</Label> |
|
<Input |
|
id="discussion-thread-title" |
|
value={threadTitle} |
|
onChange={(e) => setThreadTitle(e.target.value)} |
|
placeholder={t('Enter a descriptive title for your thread')} |
|
maxLength={100} |
|
className={cn('bg-background', threadErrors.title && 'border-destructive')} |
|
/> |
|
{threadErrors.title && <p className="text-sm text-destructive">{threadErrors.title}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{threadTitle.length}/100 {t('characters')} |
|
</p> |
|
</div> |
|
|
|
{threadTopicResolved === 'literature' && ( |
|
<div className="shrink-0 space-y-2"> |
|
<div className="flex items-center gap-2"> |
|
<Book className="h-4 w-4" /> |
|
<Label className="text-sm font-medium">{t('Readings Options')}</Label> |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="sm" |
|
title={threadShowReadingsPanel ? t('Hide') : t('Configure')} |
|
onClick={() => setThreadShowReadingsPanel(!threadShowReadingsPanel)} |
|
className="ml-auto" |
|
> |
|
{threadShowReadingsPanel ? t('Hide') : t('Configure')} |
|
</Button> |
|
</div> |
|
|
|
{threadShowReadingsPanel && ( |
|
<div className="space-y-4 rounded-lg border bg-muted/30 p-4"> |
|
<div className="flex items-center justify-between"> |
|
<div className="flex items-center gap-2"> |
|
<Book className="h-4 w-4 text-primary" /> |
|
<Label htmlFor="discussion-reading-group" className="text-sm"> |
|
{t('Reading group entry')} |
|
</Label> |
|
</div> |
|
<Switch |
|
id="discussion-reading-group" |
|
checked={threadIsReadingGroup} |
|
onCheckedChange={setThreadIsReadingGroup} |
|
/> |
|
</div> |
|
|
|
{threadIsReadingGroup && ( |
|
<div className="space-y-4"> |
|
<div className="space-y-2"> |
|
<Label htmlFor="discussion-reading-author">{t('Author')}</Label> |
|
<Input |
|
id="discussion-reading-author" |
|
value={threadReadingAuthor} |
|
onChange={(e) => setThreadReadingAuthor(e.target.value)} |
|
placeholder={t('Enter the author name')} |
|
className={threadErrors.author ? 'border-destructive' : ''} |
|
/> |
|
{threadErrors.author && <p className="text-sm text-destructive">{threadErrors.author}</p>} |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="discussion-reading-subject">{t('Subject (Book Title)')}</Label> |
|
<Input |
|
id="discussion-reading-subject" |
|
value={threadReadingSubject} |
|
onChange={(e) => setThreadReadingSubject(e.target.value)} |
|
placeholder={t('Enter the book title')} |
|
className={threadErrors.subject ? 'border-destructive' : ''} |
|
/> |
|
{threadErrors.subject && <p className="text-sm text-destructive">{threadErrors.subject}</p>} |
|
</div> |
|
|
|
<p className="text-xs text-muted-foreground"> |
|
{t( |
|
'This will add additional tags for author and subject to help organize reading group discussions.' |
|
)} |
|
</p> |
|
</div> |
|
)} |
|
</div> |
|
)} |
|
</div> |
|
)} |
|
</div> |
|
)} |
|
|
|
{/* 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')} |
|
</Label> |
|
<Input |
|
id="article-dtag" |
|
value={articleDTag} |
|
onChange={(e) => setArticleDTag(e.target.value)} |
|
placeholder={t('e.g., my-article-title')} |
|
/> |
|
<p className="text-xs text-muted-foreground">{t('articleDTagDefaultHint')}</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> |
|
)} |
|
|
|
{/* Citation metadata fields */} |
|
{(isCitationInternal || |
|
isCitationExternal || |
|
isCitationHardcopy || |
|
isCitationPrompt) && ( |
|
<div className="p-4 border rounded-lg bg-muted/30"> |
|
<div className="text-sm font-medium mb-3"> |
|
{isCitationInternal |
|
? t('Internal Citation Settings') |
|
: isCitationExternal |
|
? t('External Citation Settings') |
|
: isCitationHardcopy |
|
? t('Hardcopy Citation Settings') |
|
: t('Prompt Citation Settings')} |
|
</div> |
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> |
|
{/* Prompt Citation specific fields - shown first if prompt */} |
|
{isCitationPrompt && ( |
|
<> |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-prompt-llm" className="text-sm font-medium"> |
|
{t('Language Model')} <span className="text-destructive">*</span> |
|
</Label> |
|
<Input |
|
id="citation-prompt-llm" |
|
value={citationPromptLlm} |
|
onChange={(e) => setCitationPromptLlm(e.target.value)} |
|
placeholder={t('e.g., GPT-4, Claude, etc. (required)')} |
|
className={!citationPromptLlm.trim() ? 'border-destructive' : ''} |
|
/> |
|
<p className="text-xs text-muted-foreground"> |
|
{t('Name of the language model used')} |
|
</p> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-external-url" className="text-sm font-medium"> |
|
{t('URL')} |
|
</Label> |
|
<Input |
|
id="citation-external-url" |
|
value={citationExternalUrl} |
|
onChange={(e) => setCitationExternalUrl(e.target.value)} |
|
placeholder={t('Website where LLM was accessed (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-version" className="text-sm font-medium"> |
|
{t('Version')} |
|
</Label> |
|
<Input |
|
id="citation-version" |
|
value={citationVersion} |
|
onChange={(e) => setCitationVersion(e.target.value)} |
|
placeholder={t('Version number (optional)')} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
|
|
{/* Shared fields - not shown for prompt citations */} |
|
{!isCitationPrompt && ( |
|
<> |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-title" className="text-sm font-medium"> |
|
{t('Title')} |
|
</Label> |
|
<Input |
|
id="citation-title" |
|
value={citationTitle} |
|
onChange={(e) => setCitationTitle(e.target.value)} |
|
placeholder={t('Citation title (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-author" className="text-sm font-medium"> |
|
{t('Author')} |
|
</Label> |
|
<Input |
|
id="citation-author" |
|
value={citationAuthor} |
|
onChange={(e) => setCitationAuthor(e.target.value)} |
|
placeholder={t('Author name (optional)')} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
|
|
{/* Internal Citation specific fields */} |
|
{isCitationInternal && ( |
|
<> |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-internal-ctag" className="text-sm font-medium"> |
|
{t('C-Tag')} <span className="text-destructive">*</span> |
|
</Label> |
|
<Input |
|
id="citation-internal-ctag" |
|
value={citationInternalCTag} |
|
onChange={(e) => setCitationInternalCTag(e.target.value)} |
|
placeholder={t('kind:pubkey:hex format (required)')} |
|
className={!citationInternalCTag.trim() ? 'border-destructive' : ''} |
|
/> |
|
<p className="text-xs text-muted-foreground"> |
|
{t('Reference to the cited Nostr event in kind:pubkey:hex format')} |
|
</p> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-internal-relay-hint" className="text-sm font-medium"> |
|
{t('Relay Hint')} |
|
</Label> |
|
<Input |
|
id="citation-internal-relay-hint" |
|
value={citationInternalRelayHint} |
|
onChange={(e) => setCitationInternalRelayHint(e.target.value)} |
|
placeholder={t('Relay URL (optional)')} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
|
|
{/* External Citation specific fields */} |
|
{isCitationExternal && ( |
|
<> |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-external-url" className="text-sm font-medium"> |
|
{t('URL')} <span className="text-destructive">*</span> |
|
</Label> |
|
<Input |
|
id="citation-external-url" |
|
value={citationExternalUrl} |
|
onChange={(e) => setCitationExternalUrl(e.target.value)} |
|
placeholder={t('https://example.com (required)')} |
|
className={!citationExternalUrl.trim() ? 'border-destructive' : ''} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-external-open-timestamp" className="text-sm font-medium"> |
|
{t('Open Timestamp')} |
|
</Label> |
|
<Input |
|
id="citation-external-open-timestamp" |
|
value={citationExternalOpenTimestamp} |
|
onChange={(e) => setCitationExternalOpenTimestamp(e.target.value)} |
|
placeholder={t('e tag of kind 1040 event (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-published-by" className="text-sm font-medium"> |
|
{t('Published By')} |
|
</Label> |
|
<Input |
|
id="citation-published-by" |
|
value={citationPublishedBy} |
|
onChange={(e) => setCitationPublishedBy(e.target.value)} |
|
placeholder={t('Publisher name (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-version" className="text-sm font-medium"> |
|
{t('Version')} |
|
</Label> |
|
<Input |
|
id="citation-version" |
|
value={citationVersion} |
|
onChange={(e) => setCitationVersion(e.target.value)} |
|
placeholder={t('Version number (optional)')} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
|
|
{/* Hardcopy Citation specific fields */} |
|
{isCitationHardcopy && ( |
|
<> |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-hardcopy-page-range" className="text-sm font-medium"> |
|
{t('Page Range')} |
|
</Label> |
|
<Input |
|
id="citation-hardcopy-page-range" |
|
value={citationHardcopyPageRange} |
|
onChange={(e) => setCitationHardcopyPageRange(e.target.value)} |
|
placeholder={t('e.g., 123-145 (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-hardcopy-chapter-title" className="text-sm font-medium"> |
|
{t('Chapter Title')} |
|
</Label> |
|
<Input |
|
id="citation-hardcopy-chapter-title" |
|
value={citationHardcopyChapterTitle} |
|
onChange={(e) => setCitationHardcopyChapterTitle(e.target.value)} |
|
placeholder={t('Chapter title (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-hardcopy-editor" className="text-sm font-medium"> |
|
{t('Editor')} |
|
</Label> |
|
<Input |
|
id="citation-hardcopy-editor" |
|
value={citationHardcopyEditor} |
|
onChange={(e) => setCitationHardcopyEditor(e.target.value)} |
|
placeholder={t('Editor name (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-hardcopy-published-in" className="text-sm font-medium"> |
|
{t('Published In')} |
|
</Label> |
|
<Input |
|
id="citation-hardcopy-published-in" |
|
value={citationHardcopyPublishedIn} |
|
onChange={(e) => setCitationHardcopyPublishedIn(e.target.value)} |
|
placeholder={t('Journal/Publication name (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-hardcopy-volume" className="text-sm font-medium"> |
|
{t('Volume')} |
|
</Label> |
|
<Input |
|
id="citation-hardcopy-volume" |
|
value={citationHardcopyVolume} |
|
onChange={(e) => setCitationHardcopyVolume(e.target.value)} |
|
placeholder={t('Volume number (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-hardcopy-doi" className="text-sm font-medium"> |
|
{t('DOI')} |
|
</Label> |
|
<Input |
|
id="citation-hardcopy-doi" |
|
value={citationHardcopyDoi} |
|
onChange={(e) => setCitationHardcopyDoi(e.target.value)} |
|
placeholder={t('Digital Object Identifier (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-published-by" className="text-sm font-medium"> |
|
{t('Published By')} |
|
</Label> |
|
<Input |
|
id="citation-published-by" |
|
value={citationPublishedBy} |
|
onChange={(e) => setCitationPublishedBy(e.target.value)} |
|
placeholder={t('Publisher name (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-version" className="text-sm font-medium"> |
|
{t('Version')} |
|
</Label> |
|
<Input |
|
id="citation-version" |
|
value={citationVersion} |
|
onChange={(e) => setCitationVersion(e.target.value)} |
|
placeholder={t('Version number (optional)')} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
|
|
{/* Shared date fields - not shown for prompt citations */} |
|
{!isCitationPrompt && ( |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-published-on" className="text-sm font-medium"> |
|
{t('Published On')} |
|
</Label> |
|
<Input |
|
id="citation-published-on" |
|
type="date" |
|
value={citationPublishedOn} |
|
onChange={(e) => setCitationPublishedOn(e.target.value)} |
|
/> |
|
</div> |
|
)} |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-accessed-on" className="text-sm font-medium"> |
|
{t('Accessed On')} {(isCitationExternal || isCitationHardcopy || isCitationPrompt) && <span className="text-destructive">*</span>} |
|
</Label> |
|
<Input |
|
id="citation-accessed-on" |
|
type="date" |
|
value={citationAccessedOn} |
|
onChange={(e) => setCitationAccessedOn(e.target.value)} |
|
className={(isCitationExternal || isCitationHardcopy || isCitationPrompt) && !citationAccessedOn.trim() ? 'border-destructive' : ''} |
|
/> |
|
</div> |
|
|
|
{/* Summary field - different label for prompt citations - spans full width on desktop */} |
|
<div className="space-y-2 md:col-span-2"> |
|
<Label htmlFor="citation-summary" className="text-sm font-medium"> |
|
{isCitationPrompt ? t('Prompt Conversation Script') : t('Summary')} |
|
</Label> |
|
<Textarea |
|
id="citation-summary" |
|
value={citationSummary} |
|
onChange={(e) => setCitationSummary(e.target.value)} |
|
placeholder={isCitationPrompt ? t('The full prompt conversation (optional)') : t('Brief summary (optional)')} |
|
rows={3} |
|
/> |
|
</div> |
|
|
|
{/* Shared optional fields - not shown for prompt citations */} |
|
{!isCitationPrompt && ( |
|
<> |
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-location" className="text-sm font-medium"> |
|
{t('Location')} |
|
</Label> |
|
<Input |
|
id="citation-location" |
|
value={citationLocation} |
|
onChange={(e) => setCitationLocation(e.target.value)} |
|
placeholder={t('Location (optional)')} |
|
/> |
|
</div> |
|
|
|
<div className="space-y-2"> |
|
<Label htmlFor="citation-geohash" className="text-sm font-medium"> |
|
{t('Geohash')} |
|
</Label> |
|
<Input |
|
id="citation-geohash" |
|
value={citationGeohash} |
|
onChange={(e) => setCitationGeohash(e.target.value)} |
|
placeholder={t('Geohash (optional)')} |
|
/> |
|
</div> |
|
</> |
|
)} |
|
</div> |
|
</div> |
|
)} |
|
|
|
<NeventPickerProvider> |
|
<PostTextarea |
|
ref={textareaRef} |
|
text={text} |
|
setText={setText} |
|
defaultContent={defaultContent} |
|
parentEvent={isDiscussionThread && !parentEvent ? THREAD_POST_EDITOR_PARENT : parentEvent} |
|
onSubmit={() => post()} |
|
className={cn( |
|
isPoll ? 'min-h-20' : 'min-h-52', |
|
isDiscussionThread && threadErrors.content && 'border-destructive' |
|
)} |
|
onUploadStart={handleUploadStart} |
|
onUploadProgress={handleUploadProgress} |
|
onUploadEnd={handleUploadEnd} |
|
onUploadSuccess={handleMediaUploadSuccess} |
|
kind={getDeterminedKind} |
|
highlightData={isHighlight ? highlightData : undefined} |
|
pollCreateData={isPoll ? pollCreateData : undefined} |
|
getDraftEventJson={getDraftEventJson} |
|
extraPreviewTags={ |
|
isDiscussionThread && !parentEvent ? discussionPreviewExtraTags : rssReplyExtraPreviewTags |
|
} |
|
addClientTag={addClientTag} |
|
mediaImetaTags={mediaImetaTags} |
|
mediaUrl={mediaUrl} |
|
headerActions={ |
|
!parentEvent ? (() => { |
|
const ActiveIcon = |
|
isLongFormArticle ? FileText : |
|
isWikiArticle ? FileText : |
|
isWikiArticleMarkdown ? FileText : |
|
isPublicationContent ? Book : |
|
isCitationInternal || isCitationExternal || isCitationHardcopy || isCitationPrompt ? Quote : |
|
isHighlight ? Highlighter : |
|
isPublicMessage ? MessageCircle : |
|
isPoll ? ListTodo : |
|
isDiscussionThread ? MessagesSquare : |
|
mediaNoteKind !== null ? Upload : |
|
StickyNote |
|
const activeLabel = |
|
isLongFormArticle ? t('Long-form Article') : |
|
isWikiArticle ? t('Wiki Article (AsciiDoc)') : |
|
isWikiArticleMarkdown ? t('Wiki Article (Markdown)') : |
|
isPublicationContent ? t('Publication Note') : |
|
isCitationInternal ? t('Internal Citation') : |
|
isCitationExternal ? t('External Citation') : |
|
isCitationHardcopy ? t('Hardcopy Citation') : |
|
isCitationPrompt ? t('Prompt Citation') : |
|
isHighlight ? t('Highlight') : |
|
isPublicMessage ? t('Public Message') : |
|
isPoll ? t('Poll') : |
|
isDiscussionThread ? t('Thread') : |
|
mediaNoteKind !== null ? t('Media Note') : |
|
t('Short Note') |
|
return ( |
|
<div className="flex flex-wrap items-center justify-end gap-1.5"> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
size="sm" |
|
className="h-8 gap-1.5 text-sm font-normal shrink-0" |
|
disabled={!canUseMediaKindFromUrlButton} |
|
title={ |
|
canUseMediaKindFromUrlButton |
|
? t('Use image/audio/video note kind for the media URL in the editor') |
|
: t('Media kind (disabled): add imeta tags, a media URL, or upload media first') |
|
} |
|
onClick={handleUseMediaNoteKindFromUrl} |
|
> |
|
<Upload className="h-3.5 w-3.5 shrink-0" /> |
|
<span className="hidden sm:inline max-w-[7.5rem] truncate">{t('Media kind')}</span> |
|
</Button> |
|
<DropdownMenu> |
|
<DropdownMenuTrigger asChild> |
|
<Button variant="outline" size="sm" className="gap-1.5 h-8 text-sm font-normal"> |
|
<ActiveIcon className="h-3.5 w-3.5 shrink-0" /> |
|
<span className="max-w-[120px] truncate">{activeLabel}</span> |
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" /> |
|
</Button> |
|
</DropdownMenuTrigger> |
|
<DropdownMenuContent align="end" className="w-64"> |
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1"> |
|
{t('Note type')} |
|
</DropdownMenuLabel> |
|
<DropdownMenuItem onClick={handlePlainNoteMode} className="gap-3 py-2 cursor-pointer"> |
|
<StickyNote className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Short Note')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Plain text note (kind 1)')}</span> |
|
</div> |
|
{isPlainShortNoteToolbar && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => mediaUploaderBtnRef.current?.click()} className="gap-3 py-2 cursor-pointer"> |
|
<Upload className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Media Note')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Attach image, audio, or video')}</span> |
|
</div> |
|
{mediaNoteKind !== null && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuSeparator /> |
|
<DropdownMenuItem onClick={handleHighlightToggle} className="gap-3 py-2 cursor-pointer"> |
|
<Highlighter className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Highlight')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Save a quote or passage')}</span> |
|
</div> |
|
{isHighlight && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={handlePublicMessageToggle} className="gap-3 py-2 cursor-pointer"> |
|
<MessageCircle className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Public Message')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Public direct message (kind 4)')}</span> |
|
</div> |
|
{isPublicMessage && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={handlePollToggle} className="gap-3 py-2 cursor-pointer"> |
|
<ListTodo className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Poll')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Create a voting poll')}</span> |
|
</div> |
|
{isPoll && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => checkLogin(() => handleDiscussionThreadToggle())} className="gap-3 py-2 cursor-pointer"> |
|
<MessagesSquare className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Thread')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Start a discussion thread')}</span> |
|
</div> |
|
{isDiscussionThread && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuSeparator /> |
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1"> |
|
{t('Articles')} |
|
</DropdownMenuLabel> |
|
<DropdownMenuItem onClick={() => handleArticleToggle('longform')} className="gap-3 py-2 cursor-pointer"> |
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Long-form Article')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Markdown article (NIP-23)')}</span> |
|
</div> |
|
{isLongFormArticle && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => handleArticleToggle('wiki')} className="gap-3 py-2 cursor-pointer"> |
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Wiki Article (AsciiDoc)')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('AsciiDoc wiki contribution')}</span> |
|
</div> |
|
{isWikiArticle && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => handleArticleToggle('wiki-markdown')} className="gap-3 py-2 cursor-pointer"> |
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Wiki Article (Markdown)')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Markdown wiki contribution')}</span> |
|
</div> |
|
{isWikiArticleMarkdown && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
{hasPrivateRelaysAvailable && ( |
|
<DropdownMenuItem onClick={() => handleArticleToggle('publication')} className="gap-3 py-2 cursor-pointer"> |
|
<Book className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Publication Note')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Private relay publication')}</span> |
|
</div> |
|
{isPublicationContent && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
)} |
|
<DropdownMenuSeparator /> |
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground px-2 py-1"> |
|
{t('Citations')} |
|
</DropdownMenuLabel> |
|
{hasPrivateRelaysAvailable ? ( |
|
<> |
|
<DropdownMenuItem onClick={() => handleCitationToggle('internal')} className="gap-3 py-2 cursor-pointer"> |
|
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Internal Citation')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Cite from private relay')}</span> |
|
</div> |
|
{isCitationInternal && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => handleCitationToggle('external')} className="gap-3 py-2 cursor-pointer"> |
|
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('External Citation')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Cite from external source')}</span> |
|
</div> |
|
{isCitationExternal && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => handleCitationToggle('hardcopy')} className="gap-3 py-2 cursor-pointer"> |
|
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Hardcopy Citation')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Physical source citation')}</span> |
|
</div> |
|
{isCitationHardcopy && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
<DropdownMenuItem onClick={() => handleCitationToggle('prompt')} className="gap-3 py-2 cursor-pointer"> |
|
<Quote className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Prompt Citation')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('AI / LLM prompt citation')}</span> |
|
</div> |
|
{isCitationPrompt && <Check className="h-4 w-4 shrink-0 text-primary" />} |
|
</DropdownMenuItem> |
|
</> |
|
) : ( |
|
<div className="px-2 py-1.5 text-xs text-muted-foreground"> |
|
{t('Citations require private relays (NIP-65).')} |
|
</div> |
|
)} |
|
<DropdownMenuSeparator /> |
|
<DropdownMenuItem onClick={() => checkLogin(() => setCreateCustomEventOpen(true))} className="gap-3 py-2 cursor-pointer"> |
|
<HelpCircle className="h-4 w-4 shrink-0 text-muted-foreground" /> |
|
<div className="flex flex-col flex-1 min-w-0"> |
|
<span className="font-medium leading-none">{t('Custom Event')}</span> |
|
<span className="text-xs text-muted-foreground mt-0.5">{t('Create event with custom kind')}</span> |
|
</div> |
|
</DropdownMenuItem> |
|
</DropdownMenuContent> |
|
</DropdownMenu> |
|
</div> |
|
) |
|
})() : undefined |
|
} |
|
/> |
|
{isDiscussionThread && !parentEvent && ( |
|
<div className="flex min-w-0 flex-col gap-1"> |
|
{threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>} |
|
<p className="text-xs text-muted-foreground"> |
|
{text.length}/5000 {t('characters')} |
|
</p> |
|
</div> |
|
)} |
|
{isPoll && ( |
|
<PollEditor |
|
pollCreateData={pollCreateData} |
|
setPollCreateData={setPollCreateData} |
|
setIsPoll={setIsPoll} |
|
content={text} |
|
/> |
|
)} |
|
{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 && ( |
|
<div |
|
className={cn( |
|
'shrink-0', |
|
isDiscussionThread && threadErrors.relay && 'rounded-md ring-1 ring-destructive' |
|
)} |
|
> |
|
<PostRelaySelector |
|
setIsProtectedEvent={setIsProtectedEvent} |
|
setAdditionalRelayUrls={setAdditionalRelayUrls} |
|
parentEvent={parentEvent} |
|
openFrom={openFrom} |
|
content={text} |
|
isPublicMessage={isPublicMessage} |
|
mentions={extractedMentions} |
|
/> |
|
{isDiscussionThread && threadErrors.relay && ( |
|
<p className="mt-1 text-sm text-destructive">{threadErrors.relay}</p> |
|
)} |
|
</div> |
|
)} |
|
{/* Hidden uploader for the "Media Note" dropdown item */} |
|
{!parentEvent && ( |
|
<Uploader |
|
onUploadSuccess={handleMediaUploadSuccess} |
|
onUploadStart={handleUploadStart} |
|
onUploadEnd={handleUploadEnd} |
|
onProgress={handleUploadProgress} |
|
accept="image/*,audio/*,video/*,.mkv,.mka,video/x-matroska,audio/x-matroska" |
|
className="sr-only" |
|
> |
|
<button ref={mediaUploaderBtnRef} type="button" aria-hidden="true" tabIndex={-1} /> |
|
</Uploader> |
|
)} |
|
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0"> |
|
<div className="flex gap-2 items-center min-w-0 shrink-0"> |
|
{/* Audio button for replies and new PMs - placed before image button */} |
|
{(parentEvent || isPublicMessage) && ( |
|
<Uploader |
|
onUploadSuccess={handleMediaUploadSuccess} |
|
onUploadStart={handleUploadStart} |
|
onUploadEnd={handleUploadEnd} |
|
onProgress={handleUploadProgress} |
|
accept="audio/*,.mka,audio/x-matroska" |
|
> |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="icon" |
|
title={parentEvent ? t('Upload Audio Comment') : t('Upload Audio Message')} |
|
className={mediaNoteKind === ExtendedKind.VOICE_COMMENT || (isPublicMessage && mediaNoteKind === ExtendedKind.VOICE) ? 'bg-accent' : ''} |
|
> |
|
<Mic className="h-4 w-4" /> |
|
</Button> |
|
</Uploader> |
|
)} |
|
<Uploader |
|
onUploadSuccess={handleMediaUploadSuccess} |
|
onUploadStart={handleUploadStart} |
|
onUploadEnd={handleUploadEnd} |
|
onProgress={handleUploadProgress} |
|
accept="image/*" |
|
> |
|
<Button type="button" variant="ghost" size="icon" title={t('Upload Image')}> |
|
<ImageUp /> |
|
</Button> |
|
</Uploader> |
|
<Separator orientation="vertical" className="h-6 shrink-0" /> |
|
{/* 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 type="button" variant="ghost" size="icon" title={t('Insert emoji')}> |
|
<Smile /> |
|
</Button> |
|
</EmojiPickerDialog> |
|
)} |
|
<GifPicker |
|
onSelect={(gifUrl) => { |
|
textareaRef.current?.insertText(gifUrl) |
|
}} |
|
> |
|
<Button type="button" variant="ghost" size="icon" title={t('Insert GIF')}> |
|
<Film className="h-4 w-4" /> |
|
</Button> |
|
</GifPicker> |
|
<MemePicker |
|
onSelect={(memeUrl) => { |
|
textareaRef.current?.insertText(memeUrl) |
|
}} |
|
> |
|
<Button type="button" variant="ghost" size="icon" title={t('Insert meme')}> |
|
<Laugh className="h-4 w-4" /> |
|
</Button> |
|
</MemePicker> |
|
<Separator orientation="vertical" className="h-6 shrink-0" /> |
|
<MentionAndEventToolbarButtons |
|
insertAtCursor={(text) => textareaRef.current?.insertText(text)} |
|
variant="ghost" |
|
/> |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="icon" |
|
title={t('More options')} |
|
className={showMoreOptions ? 'bg-accent' : ''} |
|
onClick={() => setShowMoreOptions((pre) => !pre)} |
|
> |
|
<Settings /> |
|
</Button> |
|
</div> |
|
<div className="flex gap-2 items-center shrink-0"> |
|
<Mentions |
|
content={text} |
|
parentEvent={parentEvent} |
|
mentions={mentions} |
|
setMentions={setMentions} |
|
/> |
|
<div className="flex gap-2 items-center max-sm:hidden"> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
title={t('Clear')} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
handleClear() |
|
}} |
|
> |
|
{t('Clear')} |
|
</Button> |
|
<Button |
|
type="button" |
|
variant="secondary" |
|
title={t('Cancel')} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
close() |
|
}} |
|
> |
|
{t('Cancel')} |
|
</Button> |
|
<Button |
|
type="submit" |
|
title={ |
|
parentEvent |
|
? t('Reply') |
|
: isPublicMessage |
|
? t('Send Public Message') |
|
: isDiscussionThread |
|
? t('Create Thread') |
|
: t('Post') |
|
} |
|
disabled={!canPost} |
|
onClick={post} |
|
> |
|
{posting && ( |
|
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden /> |
|
)} |
|
{parentEvent |
|
? t('Reply') |
|
: isPublicMessage |
|
? t('Send Public Message') |
|
: isDiscussionThread |
|
? t('Create Thread') |
|
: 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 |
|
type="button" |
|
className="w-full" |
|
variant="outline" |
|
title={t('Clear')} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
handleClear() |
|
}} |
|
> |
|
{t('Clear')} |
|
</Button> |
|
<Button |
|
type="button" |
|
className="w-full" |
|
variant="secondary" |
|
title={t('Cancel')} |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
close() |
|
}} |
|
> |
|
{t('Cancel')} |
|
</Button> |
|
<Button |
|
className="w-full" |
|
type="submit" |
|
title={ |
|
parentEvent |
|
? t('Reply') |
|
: isPublicMessage |
|
? t('Send Public Message') |
|
: isDiscussionThread |
|
? t('Create Thread') |
|
: t('Post') |
|
} |
|
disabled={!canPost} |
|
onClick={post} |
|
> |
|
{posting && ( |
|
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden /> |
|
)} |
|
{parentEvent ? t('Reply') : isDiscussionThread ? t('Create Thread') : t('Post')} |
|
</Button> |
|
</div> |
|
|
|
{/* Media Kind Selection Dialog */} |
|
<Dialog open={showMediaKindDialog} onOpenChange={setShowMediaKindDialog}> |
|
<DialogContent> |
|
<DialogHeader> |
|
<DialogTitle>{t('Select Media Type')}</DialogTitle> |
|
<DialogDescription> |
|
{pendingMediaUpload && ( |
|
<> |
|
{t('This file could be either audio or video. Please select the correct type:')} |
|
<br /> |
|
<span className="text-xs text-muted-foreground mt-2 block"> |
|
{pendingMediaUpload.file.name} |
|
</span> |
|
</> |
|
)} |
|
</DialogDescription> |
|
</DialogHeader> |
|
<div className="flex flex-col gap-3 py-4"> |
|
<Button |
|
variant="outline" |
|
className="flex items-center justify-start gap-3 h-auto p-4" |
|
onClick={() => { |
|
// User selected audio - always use VOICE (kind 1222) |
|
handleMediaKindSelection(ExtendedKind.VOICE) |
|
}} |
|
> |
|
<Music className="h-5 w-5" /> |
|
<div className="flex flex-col items-start"> |
|
<span className="font-medium">{t('Audio')}</span> |
|
<span className="text-xs text-muted-foreground">{t('Voice note or audio file')}</span> |
|
</div> |
|
</Button> |
|
<Button |
|
variant="outline" |
|
className="flex items-center justify-start gap-3 h-auto p-4" |
|
onClick={() => { |
|
// Get duration to determine if it should be VIDEO (kind 21) or SHORT_VIDEO (kind 22) |
|
const file = pendingMediaUpload?.file |
|
if (file) { |
|
// Create a temporary media element to get duration |
|
const url = URL.createObjectURL(file) |
|
const media = document.createElement('video') |
|
|
|
media.onloadedmetadata = () => { |
|
const duration = media.duration || 0 |
|
URL.revokeObjectURL(url) |
|
// Video files longer than 10 minutes (600 seconds) are long videos (kind 21) |
|
// Otherwise use short video (kind 22) |
|
const selectedKind = duration > 600 ? ExtendedKind.VIDEO : ExtendedKind.SHORT_VIDEO |
|
handleMediaKindSelection(selectedKind) |
|
} |
|
|
|
media.onerror = () => { |
|
URL.revokeObjectURL(url) |
|
// Fallback to SHORT_VIDEO if we can't determine duration |
|
handleMediaKindSelection(ExtendedKind.SHORT_VIDEO) |
|
} |
|
|
|
media.src = url |
|
media.load() |
|
|
|
// Timeout after 3 seconds |
|
setTimeout(() => { |
|
URL.revokeObjectURL(url) |
|
handleMediaKindSelection(ExtendedKind.SHORT_VIDEO) |
|
}, 3000) |
|
} else { |
|
// Fallback to SHORT_VIDEO if no file |
|
handleMediaKindSelection(ExtendedKind.SHORT_VIDEO) |
|
} |
|
}} |
|
> |
|
<Video className="h-5 w-5" /> |
|
<div className="flex flex-col items-start"> |
|
<span className="font-medium">{t('Video')}</span> |
|
<span className="text-xs text-muted-foreground">{t('Video file')}</span> |
|
</div> |
|
</Button> |
|
</div> |
|
</DialogContent> |
|
</Dialog> |
|
<EditOrCloneEventDialog |
|
open={createCustomEventOpen} |
|
onOpenChange={setCreateCustomEventOpen} |
|
mode="create" |
|
/> |
|
</NeventPickerProvider> |
|
</div> |
|
) |
|
}
|
|
|