diff --git a/src/components/Note/Highlight/index.tsx b/src/components/Note/Highlight/index.tsx index 7885f93..c089882 100644 --- a/src/components/Note/Highlight/index.tsx +++ b/src/components/Note/Highlight/index.tsx @@ -4,6 +4,42 @@ import { nip19 } from 'nostr-tools' import logger from '@/lib/logger' import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview' +/** + * Check if a string is a URL or Nostr address + */ +function isUrlOrNostrAddress(value: string | undefined): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // Check if it's a URL (http://, https://, or starts with common URL patterns) + try { + if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('ws://') || value.startsWith('wss://')) { + new URL(value) // Validate it's a proper URL + return true + } + } catch { + // Not a valid URL + } + + // Check if it's a Nostr address (nostr: prefix or bech32 encoded) + if (value.startsWith('nostr:')) { + return true + } + + // Check if it's a bech32 encoded Nostr address + try { + const decoded = nip19.decode(value) + if (['npub', 'nprofile', 'nevent', 'naddr', 'note', 'nrelay'].includes(decoded.type)) { + return true + } + } catch { + // Not a valid Nostr address + } + + return false +} + export default function Highlight({ event, className @@ -15,6 +51,7 @@ export default function Highlight({ // Extract the source (e-tag, a-tag, or r-tag) with improved priority handling let source = null + let quoteSource: string | null = null // For plain text r-tags that aren't URLs/Nostr addresses let sourceTag: string[] | undefined // Check for 'source' marker first (highest priority) @@ -50,13 +87,13 @@ export default function Highlight({ // Process the selected source tag if (sourceTag) { - if (sourceTag[0] === 'e') { + if (sourceTag[0] === 'e' && sourceTag[1]) { source = { type: 'event' as const, value: sourceTag[1], bech32: nip19.noteEncode(sourceTag[1]) } - } else if (sourceTag[0] === 'a') { + } else if (sourceTag[0] === 'a' && sourceTag[1]) { const [kind, pubkey, identifier] = sourceTag[1].split(':') const relay = sourceTag[2] source = { @@ -70,10 +107,16 @@ export default function Highlight({ }) } } else if (sourceTag[0] === 'r') { - source = { - type: 'url' as const, - value: sourceTag[1], - bech32: sourceTag[1] + // Check if the r-tag value is a URL or Nostr address + if (sourceTag[1] && isUrlOrNostrAddress(sourceTag[1])) { + source = { + type: 'url' as const, + value: sourceTag[1], + bech32: sourceTag[1] + } + } else if (sourceTag[1]) { + // It's plain text, store it as a quote source + quoteSource = sourceTag[1] } } } @@ -90,30 +133,56 @@ export default function Highlight({
{/* Full quoted text with highlighted portion */} {context && ( -
+
{contextTag && highlightedText ? ( // If we have both context and highlighted text, show the highlight within the context
- {context.split(highlightedText).map((part, index) => ( - - {part} - {index < context.split(highlightedText).length - 1 && ( - - {highlightedText} - - )} - - ))} + {(() => { + // Strip outer quotation marks if present + let cleanContext = context.trim() + if (cleanContext.startsWith('"') && cleanContext.endsWith('"')) { + cleanContext = cleanContext.slice(1, -1).trim() + } + // Strip outer quotation marks from highlighted text if present + let cleanHighlightedText = highlightedText.trim() + if (cleanHighlightedText.startsWith('"') && cleanHighlightedText.endsWith('"')) { + cleanHighlightedText = cleanHighlightedText.slice(1, -1).trim() + } + return cleanContext.split(cleanHighlightedText).map((part, index) => ( + + {part} + {index < cleanContext.split(cleanHighlightedText).length - 1 && ( + + {cleanHighlightedText} + + )} + + )) + })()}
) : ( // If no context tag, just show the content as a regular quote -
- "{context}" -
+
+ {(() => { + // Strip outer quotation marks if present + let cleanContext = context.trim() + if (cleanContext.startsWith('"') && cleanContext.endsWith('"')) { + cleanContext = cleanContext.slice(1, -1).trim() + } + return cleanContext + })()} +
)}
)} + {/* Quote source (plain text r-tag) */} + {quoteSource && ( +
+ {quoteSource.trimStart().startsWith('—') ? quoteSource : `— ${quoteSource}`} +
+ )} + {/* Source preview card */} {source && (
diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index ea1c659..b69b9f9 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -7,6 +7,8 @@ import { MobileMenu } from './MobileMenu' import RawEventDialog from './RawEventDialog' import ReportDialog from './ReportDialog' import { SubMenuAction, useMenuActions } from './useMenuActions' +import PostEditor from '../PostEditor' +import { HighlightData } from '../PostEditor/HighlightEditor' export default function NoteOptions({ event, className }: { event: Event; className?: string }) { const { isSmallScreen } = useScreenSize() @@ -16,6 +18,9 @@ export default function NoteOptions({ event, className }: { event: Event; classN const [showSubMenu, setShowSubMenu] = useState(false) const [activeSubMenu, setActiveSubMenu] = useState([]) const [subMenuTitle, setSubMenuTitle] = useState('') + const [isPostEditorOpen, setIsPostEditorOpen] = useState(false) + const [initialHighlightData, setInitialHighlightData] = useState(undefined) + const [highlightDefaultContent, setHighlightDefaultContent] = useState('') const closeDrawer = () => { setIsDrawerOpen(false) @@ -38,7 +43,13 @@ export default function NoteOptions({ event, className }: { event: Event; classN showSubMenuActions, setIsRawEventDialogOpen, setIsReportDialogOpen, - isSmallScreen + isSmallScreen, + openHighlightEditor: (highlightData: HighlightData, eventContent?: string) => { + setInitialHighlightData(highlightData) + setHighlightDefaultContent(eventContent || '') + setIsPostEditorOpen(true) + closeDrawer() + } }) const trigger = useMemo( @@ -81,6 +92,18 @@ export default function NoteOptions({ event, className }: { event: Event; classN isOpen={isReportDialogOpen} closeDialog={() => setIsReportDialogOpen(false)} /> + { + setIsPostEditorOpen(open) + if (!open) { + setInitialHighlightData(undefined) + setHighlightDefaultContent('') + } + }} + defaultContent={highlightDefaultContent} + initialHighlightData={initialHighlightData} + />
) } diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index a4d4c2d..7e4acb3 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -11,7 +11,7 @@ import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import client from '@/services/client.service' -import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen } from 'lucide-react' +import { Bell, BellOff, Code, Copy, Link, SatelliteDish, Trash2, TriangleAlert, Pin, FileDown, Globe, BookOpen, Highlighter } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { nip19 } from 'nostr-tools' import { useMemo, useState, useEffect } from 'react' @@ -42,6 +42,7 @@ interface UseMenuActionsProps { setIsRawEventDialogOpen: (open: boolean) => void setIsReportDialogOpen: (open: boolean) => void isSmallScreen: boolean + openHighlightEditor?: (highlightData: import('../PostEditor/HighlightEditor').HighlightData, eventContent?: string) => void } export function useMenuActions({ @@ -50,7 +51,8 @@ export function useMenuActions({ showSubMenuActions, setIsRawEventDialogOpen, setIsReportDialogOpen, - isSmallScreen + isSmallScreen, + openHighlightEditor }: UseMenuActionsProps) { const { t } = useTranslation() const { pubkey, attemptDelete, publish } = useNostr() @@ -347,6 +349,21 @@ export function useMenuActions({ } }, [isArticleType, event, dTag]) + // Check if this is an OP event that can be highlighted + const isOPEvent = useMemo(() => { + return ( + event.kind === kinds.ShortTextNote || // 1 + event.kind === kinds.LongFormArticle || // 30023 + event.kind === ExtendedKind.WIKI_ARTICLE || // 30818 + event.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || // 30817 + event.kind === ExtendedKind.PUBLICATION || // 30040 + event.kind === ExtendedKind.PUBLICATION_CONTENT || // 30041 + event.kind === ExtendedKind.DISCUSSION || // 11 + event.kind === ExtendedKind.COMMENT || // 1111 + (event.kind === kinds.Zap && (event.tags.some(tag => tag[0] === 'e') || event.tags.some(tag => tag[0] === 'a'))) // Zap receipt + ) + }, [event.kind, event.tags]) + const menuActions: MenuAction[] = useMemo(() => { // Export functions for articles const exportAsMarkdown = () => { @@ -443,17 +460,81 @@ export function useMenuActions({ navigator.clipboard.writeText(toNjump(getNoteBech32Id(event))) closeDrawer() } - }, - { - icon: Code, - label: t('View raw event'), + } + ] + + // Add "Create Highlight" action for OP events + if (isOPEvent && openHighlightEditor) { + actions.push({ + icon: Highlighter, + label: t('Create Highlight'), onClick: () => { - closeDrawer() - setIsRawEventDialogOpen(true) + try { + // For addressable events (publications, long-form articles with d-tag), use naddr + // For regular events, use nevent + let sourceValue: string + let sourceHexId: string | undefined + + if (kinds.isAddressableKind(event.kind) || kinds.isReplaceableKind(event.kind)) { + // Generate naddr for addressable/replaceable events + const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' + if (dTag) { + const relays = event.tags + .filter(tag => tag[0] === 'relay') + .map(tag => tag[1]) + .filter(Boolean) + + try { + sourceValue = nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + relays: relays.length > 0 ? relays : undefined + }) + sourceHexId = undefined // naddr doesn't have a single hex ID + } catch (error) { + logger.error('Error generating naddr for highlight', { error }) + // Fallback to nevent + sourceValue = getNoteBech32Id(event) + sourceHexId = event.id + } + } else { + // No d-tag, use nevent + sourceValue = getNoteBech32Id(event) + sourceHexId = event.id + } + } else { + // Regular event, use nevent + sourceValue = getNoteBech32Id(event) + sourceHexId = event.id + } + + const highlightData: import('../PostEditor/HighlightEditor').HighlightData = { + sourceType: 'nostr', + sourceValue, + sourceHexId + // context field is left empty - user can add it later if needed + } + // Pass the event content as defaultContent for the main editor field + openHighlightEditor(highlightData, event.content) + } catch (error) { + logger.error('Error creating highlight from event', { error, eventId: event.id }) + toast.error(t('Failed to create highlight')) + } }, separator: true - } - ] + }) + } + + actions.push({ + icon: Code, + label: t('View raw event'), + onClick: () => { + closeDrawer() + setIsRawEventDialogOpen(true) + }, + separator: true + }) // Add export options for article-type events if (isArticleType) { @@ -621,6 +702,7 @@ export function useMenuActions({ pubkey, isMuted, isSmallScreen, + openHighlightEditor, broadcastSubMenu, closeDrawer, showSubMenuActions, diff --git a/src/components/PostEditor/HighlightEditor.tsx b/src/components/PostEditor/HighlightEditor.tsx index 41480c7..2299894 100644 --- a/src/components/PostEditor/HighlightEditor.tsx +++ b/src/components/PostEditor/HighlightEditor.tsx @@ -149,7 +149,7 @@ export default function HighlightEditor({ value={context} onChange={(e) => setContext(e.target.value)} placeholder={t('Paste the entire original passage that contains your highlight')} - rows={3} + rows={12} />

{t('The main editor above should contain only the text you want to highlight. This field should contain the full quote or paragraph for context.')} diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index ef48703..39c35d7 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -38,12 +38,14 @@ export default function PostContent({ defaultContent = '', parentEvent, close, - openFrom + openFrom, + initialHighlightData }: { defaultContent?: string parentEvent?: Event close: () => void openFrom?: string[] + initialHighlightData?: HighlightData }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() @@ -64,11 +66,13 @@ export default function PostContent({ const [extractedMentions, setExtractedMentions] = useState([]) const [isProtectedEvent, setIsProtectedEvent] = useState(false) const [additionalRelayUrls, setAdditionalRelayUrls] = useState([]) - const [isHighlight, setIsHighlight] = useState(false) - const [highlightData, setHighlightData] = useState({ - sourceType: 'nostr', - sourceValue: '' - }) + const [isHighlight, setIsHighlight] = useState(!!initialHighlightData) + const [highlightData, setHighlightData] = useState( + initialHighlightData || { + sourceType: 'nostr', + sourceValue: '' + } + ) const [pollCreateData, setPollCreateData] = useState({ isMultipleChoice: false, options: ['', ''], @@ -106,6 +110,22 @@ export default function PostContent({ highlightData ]) + // Clear highlight data when initialHighlightData changes or is removed + useEffect(() => { + if (initialHighlightData) { + // Set highlight mode and data when provided + setIsHighlight(true) + setHighlightData(initialHighlightData) + } else { + // Clear highlight mode and data when not provided + setIsHighlight(false) + setHighlightData({ + sourceType: 'nostr', + sourceValue: '' + }) + } + }, [initialHighlightData]) + useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false @@ -469,7 +489,8 @@ export default function PostContent({ onUploadStart={handleUploadStart} onUploadProgress={handleUploadProgress} onUploadEnd={handleUploadEnd} - kind={isPublicMessage ? ExtendedKind.PUBLIC_MESSAGE : isPoll ? ExtendedKind.POLL : kinds.ShortTextNote} + kind={isHighlight ? kinds.Highlights : isPublicMessage ? ExtendedKind.PUBLIC_MESSAGE : isPoll ? ExtendedKind.POLL : kinds.ShortTextNote} + highlightData={isHighlight ? highlightData : undefined} /> {isPoll && ( { // Clean tracking parameters from URLs in the preview const cleanedContent = content.replace( @@ -28,18 +33,81 @@ export default function Preview({ } } ) - return transformCustomEmojisInContent(cleanedContent) + const { content: processed, emojiTags: tags } = transformCustomEmojisInContent(cleanedContent) + + // Build highlight tags if this is a highlight + let highlightTags: string[][] = [] + if (kind === kinds.Highlights && highlightData) { + // Add source tag + if (highlightData.sourceType === 'url') { + highlightTags.push(['r', highlightData.sourceValue, 'source']) + } else if (highlightData.sourceType === 'nostr') { + // For preview, we'll use a simple e-tag with the source value + // The actual tag building happens in createHighlightDraftEvent + if (highlightData.sourceHexId) { + highlightTags.push(['e', highlightData.sourceHexId]) + } else if (highlightData.sourceValue) { + // Try to extract hex ID from bech32 if possible + try { + const decoded = nip19.decode(highlightData.sourceValue) + if (decoded.type === 'note' || decoded.type === 'nevent') { + const hexId = decoded.type === 'note' ? decoded.data : decoded.data.id + highlightTags.push(['e', hexId]) + } else if (decoded.type === 'naddr') { + const { kind, pubkey, identifier } = decoded.data + highlightTags.push(['a', `${kind}:${pubkey}:${identifier}`]) + } + } catch { + // If decoding fails, just use the source value as-is for preview + highlightTags.push(['r', highlightData.sourceValue]) + } + } + } + + // Add context tag if provided + if (highlightData.context) { + highlightTags.push(['context', highlightData.context]) + } + } + + return { + content: processed, + emojiTags: tags, + highlightTags + } }, - [content] + [content, kind, highlightData] ) + + // Combine emoji tags and highlight tags + const allTags = useMemo(() => { + return [...emojiTags, ...highlightTags] + }, [emojiTags, highlightTags]) + + const fakeEvent = useMemo(() => { + return createFakeEvent({ + content: processedContent, + tags: allTags, + kind + }) + }, [processedContent, allTags, kind]) + + // For highlights, use the Highlight component for proper formatting + if (kind === kinds.Highlights) { + return ( + + + + ) + } + return ( diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index be82728..6aac693 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -13,7 +13,7 @@ import Text from '@tiptap/extension-text' import { TextSelection } from '@tiptap/pm/state' import { EditorContent, useEditor } from '@tiptap/react' import { Event } from 'nostr-tools' -import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react' +import { Dispatch, forwardRef, SetStateAction, useImperativeHandle, useState } from 'react' import { useTranslation } from 'react-i18next' import { ClipboardAndDropHandler } from './ClipboardAndDropHandler' import Emoji from './Emoji' @@ -21,6 +21,7 @@ import emojiSuggestion from './Emoji/suggestion' import Mention from './Mention' import mentionSuggestion from './Mention/suggestion' import Preview from './Preview' +import { HighlightData } from '../HighlightEditor' export type TPostTextareaHandle = { appendText: (text: string, addNewline?: boolean) => void @@ -41,6 +42,7 @@ const PostTextarea = forwardRef< onUploadProgress?: (file: File, progress: number) => void onUploadEnd?: (file: File) => void kind?: number + highlightData?: HighlightData } >( ( @@ -54,11 +56,13 @@ const PostTextarea = forwardRef< onUploadStart, onUploadProgress, onUploadEnd, - kind = 1 + kind = 1, + highlightData }, ref ) => { const { t } = useTranslation() + const [activeTab, setActiveTab] = useState('edit') const editor = useEditor({ extensions: [ Document, @@ -160,7 +164,7 @@ const PostTextarea = forwardRef< } return ( - + {t('Edit')} {t('Preview')} @@ -169,7 +173,7 @@ const PostTextarea = forwardRef< - + ) diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index d82875b..de59d3b 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -24,26 +24,34 @@ export default function PostEditor({ parentEvent, open, setOpen, - openFrom + openFrom, + initialHighlightData }: { defaultContent?: string parentEvent?: Event open: boolean setOpen: Dispatch openFrom?: string[] + initialHighlightData?: import('./HighlightEditor').HighlightData }) { const { isSmallScreen } = useScreenSize() + // If initialHighlightData is provided and we're creating a highlight from an event, + // we need to pass the event content as defaultContent for the main editor + // Note: This is handled separately - we'll pass the event content when opening from menu + const effectiveDefaultContent = defaultContent + const content = useMemo(() => { return ( setOpen(false)} openFrom={openFrom} + initialHighlightData={initialHighlightData} /> ) - }, [defaultContent, parentEvent, openFrom, setOpen]) + }, [effectiveDefaultContent, parentEvent, openFrom, setOpen, initialHighlightData]) if (isSmallScreen) { return ( diff --git a/src/components/UniversalContent/HighlightSourcePreview.tsx b/src/components/UniversalContent/HighlightSourcePreview.tsx index cb9ea3a..13b9d2d 100644 --- a/src/components/UniversalContent/HighlightSourcePreview.tsx +++ b/src/components/UniversalContent/HighlightSourcePreview.tsx @@ -36,7 +36,9 @@ export default function HighlightSourcePreview({ source, className }: HighlightS const decoded = nip19.decode(source.bech32) if (decoded.type === 'nevent' || decoded.type === 'note') { content = ( - +

+ +
) } } catch (error) { @@ -67,7 +69,9 @@ export default function HighlightSourcePreview({ source, className }: HighlightS const decoded = nip19.decode(source.bech32) if (decoded.type === 'naddr') { content = ( - +
+ +
) } } catch (error) { diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 2741e1f..51a0fc0 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -35,9 +35,9 @@ const CommandDialog = ({ }: DialogProps & { classNames?: { content?: string } }) => { return ( - - - + + Command Menu + Search and select a command