Browse Source

display context and handle text highlighting

imwald
Silberengel 5 months ago
parent
commit
52a31d1c21
  1. 132
      src/components/Note/Highlight.tsx
  2. 81
      src/components/Note/Highlight/index.tsx
  3. 23
      src/components/Note/index.tsx
  4. 1
      src/components/NoteList/index.tsx
  5. 38
      src/components/PostEditor/HighlightEditor.tsx
  6. 6
      src/components/PostEditor/PostContent.tsx
  7. 11
      src/lib/draft-event.ts

132
src/components/Note/Highlight.tsx

@ -1,132 +0,0 @@
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
import { createFakeEvent } from '@/lib/event'
import { toNote } from '@/lib/link'
import { isValidPubkey } from '@/lib/pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Content from '../Content'
import ContentPreview from '../ContentPreview'
import UserAvatar from '../UserAvatar'
export default function Highlight({ event, className }: { event: Event; className?: string }) {
const translatedEvent = useTranslatedEvent(event.id)
const comment = useMemo(
() => (translatedEvent?.tags ?? event.tags).find((tag) => tag[0] === 'comment')?.[1],
[event, translatedEvent]
)
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
{comment && <Content event={createFakeEvent({ content: comment })} />}
<div className="flex gap-4">
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
<div className="italic whitespace-pre-line">
{translatedEvent?.content ?? event.content}
</div>
</div>
<HighlightSource event={event} />
</div>
)
}
function HighlightSource({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const sourceTag = useMemo(() => {
let sourceTag: string[] | undefined
for (const tag of event.tags) {
if (tag[2] === 'source') {
sourceTag = tag
break
}
if (tag[0] === 'r') {
sourceTag = tag
continue
} else if (tag[0] === 'a') {
if (!sourceTag || sourceTag[0] !== 'r') {
sourceTag = tag
}
continue
} else if (tag[0] === 'e') {
if (!sourceTag || sourceTag[0] === 'e') {
sourceTag = tag
}
continue
}
}
return sourceTag
}, [event])
const { event: referenceEvent } = useFetchEvent(
sourceTag
? sourceTag[0] === 'e'
? generateBech32IdFromETag(sourceTag)
: sourceTag[0] === 'a'
? generateBech32IdFromATag(sourceTag)
: undefined
: undefined
)
const referenceEventId = useMemo(() => {
if (!sourceTag || sourceTag[0] === 'r') return
if (sourceTag[0] === 'e') {
return sourceTag[1]
}
if (sourceTag[0] === 'a') {
return generateBech32IdFromATag(sourceTag)
}
}, [sourceTag])
const pubkey = useMemo(() => {
if (referenceEvent) {
return referenceEvent.pubkey
}
if (sourceTag && sourceTag[0] === 'a') {
const [, pubkey] = sourceTag[1].split(':')
if (isValidPubkey(pubkey)) {
return pubkey
}
}
}, [sourceTag, referenceEvent])
if (!sourceTag) {
return null
}
if (sourceTag[0] === 'r') {
return (
<div className="truncate text-muted-foreground">
{t('From')}{' '}
<a
href={sourceTag[1]}
target="_blank"
rel="noopener noreferrer"
className="underline text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
{sourceTag[1]}
</a>
</div>
)
}
return (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="shrink-0">{t('From')}</div>
{pubkey && <UserAvatar userId={pubkey} size="xSmall" className="cursor-pointer" />}
{referenceEventId && (
<div
className="truncate underline pointer-events-auto cursor-pointer hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNote(referenceEvent ?? referenceEventId))
}}
>
{referenceEvent ? <ContentPreview event={referenceEvent} /> : referenceEventId}
</div>
)}
</div>
)
}

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

@ -1,7 +1,6 @@
import { SecondaryPageLink } from '@/PageManager' import { SecondaryPageLink } from '@/PageManager'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { ExternalLink, Highlighter } from 'lucide-react' import { ExternalLink, Highlighter } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@ -13,25 +12,25 @@ export default function Highlight({
event: Event event: Event
className?: string className?: string
}) { }) {
try {
const { t } = useTranslation() const { t } = useTranslation()
// Extract the source (e-tag, a-tag, or r-tag) // Extract the source (e-tag, a-tag, or r-tag) - simplified without useMemo
const source = useMemo(() => { let source = null
const eTag = event.tags.find(tag => tag[0] === 'e') const eTag = event.tags.find(tag => tag[0] === 'e')
if (eTag) { if (eTag) {
const eventId = eTag[1] const eventId = eTag[1]
return { source = {
type: 'event' as const, type: 'event' as const,
value: eventId, value: eventId,
bech32: nip19.noteEncode(eventId) bech32: nip19.noteEncode(eventId)
} }
} } else {
const aTag = event.tags.find(tag => tag[0] === 'a') const aTag = event.tags.find(tag => tag[0] === 'a')
if (aTag) { if (aTag) {
const [kind, pubkey, identifier] = aTag[1].split(':') const [kind, pubkey, identifier] = aTag[1].split(':')
const relay = aTag[2] const relay = aTag[2]
return { source = {
type: 'addressable' as const, type: 'addressable' as const,
value: aTag[1], value: aTag[1],
bech32: nip19.naddrEncode({ bech32: nip19.naddrEncode({
@ -41,42 +40,51 @@ export default function Highlight({
relays: relay ? [relay] : [] relays: relay ? [relay] : []
}) })
} }
} } else {
const rTag = event.tags.find(tag => tag[0] === 'r' && tag[2] === 'source') const rTag = event.tags.find(tag => tag[0] === 'r' && tag[2] === 'source')
if (rTag) { if (rTag) {
return { source = {
type: 'url' as const, type: 'url' as const,
value: rTag[1], value: rTag[1],
bech32: rTag[1] bech32: rTag[1]
} }
} }
}
}
return null // Extract the context (the main quote/full text being highlighted from)
}, [event.tags])
// Extract the context (optional comment/surrounding context)
const context = useMemo(() => {
const contextTag = event.tags.find(tag => tag[0] === 'context') const contextTag = event.tags.find(tag => tag[0] === 'context')
return contextTag?.[1] || '' const context = contextTag?.[1] || event.content // Default to content if no context
}, [event.tags])
// The event.content is the highlighted portion
const highlightedText = event.content
return ( return (
<div className={`relative border-l-4 border-yellow-500 bg-yellow-50/50 dark:bg-yellow-950/20 rounded-r-lg p-4 ${className || ''}`}> <div className={`bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${className || ''}`}>
<div className="flex items-start gap-3">
<Highlighter className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-1" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Highlighted text */} {/* Full quoted text with highlighted portion */}
{event.content && ( {context && (
<blockquote className="text-base font-normal mb-3 whitespace-pre-wrap break-words italic"> <div className="text-base font-normal mb-3 whitespace-pre-wrap break-words">
"{event.content}" {contextTag && highlightedText ? (
// If we have both context and highlighted text, show the highlight within the context
<div>
{context.split(highlightedText).map((part, index) => (
<span key={index}>
{part}
{index < context.split(highlightedText).length - 1 && (
<mark className="bg-green-200 dark:bg-green-800 px-1 rounded">
{highlightedText}
</mark>
)}
</span>
))}
</div>
) : (
// If no context tag, just show the content as a regular quote
<blockquote className="italic">
"{context}"
</blockquote> </blockquote>
)} )}
{/* Context (user's comment or surrounding context) - rendered as plaintext */}
{context && (
<div className="text-sm text-muted-foreground bg-background/50 rounded p-2 mb-3 whitespace-pre-wrap break-words">
{context}
</div> </div>
)} )}
@ -109,7 +117,22 @@ export default function Highlight({
)} )}
</div> </div>
</div> </div>
)
} catch (error) {
console.error('Highlight component error:', error)
return (
<div className={`relative border-l-4 border-red-500 bg-red-50/50 dark:bg-red-950/20 rounded-r-lg p-4 ${className || ''}`}>
<div className="flex items-start gap-3">
<Highlighter className="w-5 h-5 text-red-600 dark:text-red-500 shrink-0 mt-1" />
<div className="flex-1 min-w-0">
<div className="font-bold text-red-800 dark:text-red-200">Highlight Error:</div>
<div className="text-red-700 dark:text-red-300 text-sm">{String(error)}</div>
<div className="mt-2 text-sm">Content: {event.content}</div>
<div className="text-sm">Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</div>
</div>
</div>
</div> </div>
) )
}
} }

23
src/components/Note/index.tsx

@ -22,6 +22,7 @@ import CommunityDefinition from './CommunityDefinition'
import DiscussionContent from './DiscussionContent' import DiscussionContent from './DiscussionContent'
import GroupMetadata from './GroupMetadata' import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight' import Highlight from './Highlight'
import IValue from './IValue' import IValue from './IValue'
import LiveEvent from './LiveEvent' import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle' import LongFormArticle from './LongFormArticle'
@ -62,8 +63,8 @@ export default function Note({
const [showMuted, setShowMuted] = useState(false) const [showMuted, setShowMuted] = useState(false)
let content: React.ReactNode let content: React.ReactNode
if (
![ const supportedKindsList = [
...SUPPORTED_KINDS, ...SUPPORTED_KINDS,
kinds.CommunityDefinition, kinds.CommunityDefinition,
kinds.LiveEvent, kinds.LiveEvent,
@ -71,15 +72,29 @@ export default function Note({
ExtendedKind.PUBLIC_MESSAGE, ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST, ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT ExtendedKind.ZAP_RECEIPT
].includes(event.kind) ]
) {
if (!supportedKindsList.includes(event.kind)) {
console.log('Note component - rendering UnknownNote for unsupported kind:', event.kind)
content = <UnknownNote className="mt-2" event={event} /> content = <UnknownNote className="mt-2" event={event} />
} else if (mutePubkeySet.has(event.pubkey) && !showMuted) { } else if (mutePubkeySet.has(event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} /> content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} /> content = <NsfwNote show={() => setShowNsfw(true)} />
} else if (event.kind === kinds.Highlights) { } else if (event.kind === kinds.Highlights) {
// Try to render the Highlight component with error boundary
try {
content = <Highlight className="mt-2" event={event} /> content = <Highlight className="mt-2" event={event} />
} catch (error) {
console.error('Note component - Error rendering Highlight component:', error)
content = <div className="mt-2 p-4 bg-red-100 border border-red-500 rounded">
<div className="font-bold text-red-800">HIGHLIGHT ERROR:</div>
<div className="text-red-700">Error: {String(error)}</div>
<div className="mt-2">Content: {event.content}</div>
<div>Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</div>
</div>
}
} else if (event.kind === kinds.LongFormArticle) { } else if (event.kind === kinds.LongFormArticle) {
content = showFull ? ( content = showFull ? (
<LongFormArticle className="mt-2" event={event} /> <LongFormArticle className="mt-2" event={event} />

1
src/components/NoteList/index.tsx

@ -157,6 +157,7 @@ const NoteList = forwardRef(
if (!subRequests.length) return if (!subRequests.length) return
async function init() { async function init() {
setLoading(true) setLoading(true)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])

38
src/components/PostEditor/HighlightEditor.tsx

@ -11,7 +11,7 @@ export interface HighlightData {
sourceType: 'nostr' | 'url' sourceType: 'nostr' | 'url'
sourceValue: string // nevent/naddr/note/hex for nostr, https:// URL for url sourceValue: string // nevent/naddr/note/hex for nostr, https:// URL for url
sourceHexId?: string // converted hex ID for nostr sources sourceHexId?: string // converted hex ID for nostr sources
description?: string // optional comment/description context?: string // the full text/quote that the highlight is from
} }
interface HighlightEditorProps { interface HighlightEditorProps {
@ -27,7 +27,7 @@ export default function HighlightEditor({
}: HighlightEditorProps) { }: HighlightEditorProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [sourceInput, setSourceInput] = useState(highlightData.sourceValue) const [sourceInput, setSourceInput] = useState(highlightData.sourceValue)
const [description, setDescription] = useState(highlightData.description || '') const [context, setContext] = useState(highlightData.context || '')
const [error, setError] = useState<string>('') const [error, setError] = useState<string>('')
// Validate and parse the source input // Validate and parse the source input
@ -43,7 +43,7 @@ export default function HighlightEditor({
setHighlightData({ setHighlightData({
sourceType: 'url', sourceType: 'url',
sourceValue: sourceInput, sourceValue: sourceInput,
description context
}) })
return return
} }
@ -60,7 +60,7 @@ export default function HighlightEditor({
sourceType: 'nostr', sourceType: 'nostr',
sourceValue: sourceInput, sourceValue: sourceInput,
sourceHexId: hexId, sourceHexId: hexId,
description context
}) })
return return
} }
@ -75,7 +75,7 @@ export default function HighlightEditor({
sourceType: 'nostr', sourceType: 'nostr',
sourceValue: sourceInput, // Keep original for reference sourceValue: sourceInput, // Keep original for reference
sourceHexId: hexId, // Store the hex ID sourceHexId: hexId, // Store the hex ID
description context
}) })
} else if (decoded.type === 'nevent') { } else if (decoded.type === 'nevent') {
hexId = decoded.data.id hexId = decoded.data.id
@ -84,7 +84,7 @@ export default function HighlightEditor({
sourceType: 'nostr', sourceType: 'nostr',
sourceValue: sourceInput, // Keep the nevent for relay info sourceValue: sourceInput, // Keep the nevent for relay info
sourceHexId: hexId, // Store the hex ID sourceHexId: hexId, // Store the hex ID
description context
}) })
} else if (decoded.type === 'naddr') { } else if (decoded.type === 'naddr') {
// For naddr, we need to keep the full naddr string to extract kind:pubkey:identifier // For naddr, we need to keep the full naddr string to extract kind:pubkey:identifier
@ -93,7 +93,7 @@ export default function HighlightEditor({
sourceType: 'nostr', sourceType: 'nostr',
sourceValue: sourceInput, // Keep the naddr for a-tag building sourceValue: sourceInput, // Keep the naddr for a-tag building
sourceHexId: undefined, // No hex ID for addressable events sourceHexId: undefined, // No hex ID for addressable events
description context
}) })
} else { } else {
setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.')) setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.'))
@ -102,7 +102,7 @@ export default function HighlightEditor({
} catch (err) { } catch (err) {
setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.')) setError(t('Invalid source. Please enter a note ID, nevent, naddr, hex ID, or URL.'))
} }
}, [sourceInput, description, setHighlightData, t]) }, [sourceInput, context, setHighlightData, t])
return ( return (
<div className="rounded-lg border bg-muted/40 p-4 space-y-4"> <div className="rounded-lg border bg-muted/40 p-4 space-y-4">
@ -139,26 +139,28 @@ export default function HighlightEditor({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="highlight-context"> <Label htmlFor="highlight-context">
{t('Context')} <span className="text-muted-foreground text-xs">({t('optional')})</span> {t('Full Quote/Context')} <span className="text-muted-foreground text-xs">({t('optional')})</span>
</Label> </Label>
<Textarea <Textarea
id="highlight-context" id="highlight-context"
value={description} value={context}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setContext(e.target.value)}
placeholder={t('Add your comment or surrounding context for this highlight...')} placeholder={t('Enter the full text that you are highlighting from...')}
rows={3} rows={2}
maxLength={500} maxLength={500}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{description.length}/500 {t('characters')} {context.length}/500 {t('characters')}
</p> </p>
</div> </div>
<div className="text-xs text-muted-foreground bg-background/50 rounded p-2"> <div className="text-xs text-muted-foreground bg-background/50 rounded p-2">
<p className="font-medium mb-1">{t('About Highlights (NIP-84)')}</p> <p className="font-medium mb-1">{t('How to Create a Highlight (NIP-84)')}</p>
<p> <ol className="list-decimal list-inside space-y-1 mt-2">
{t('The highlighted text goes in the main content. The source and optional context will be added as tags.')} <li>{t('Enter the specific text you want to highlight in the main content area above')}</li>
</p> <li>{t('Add the source (where this text is from)')}</li>
<li>{t('Optionally, add the full quote/context to show your highlight within it')}</li>
</ol>
</div> </div>
</div> </div>
) )

6
src/components/PostEditor/PostContent.tsx

@ -62,8 +62,7 @@ export default function PostContent({
const [isHighlight, setIsHighlight] = useState(false) const [isHighlight, setIsHighlight] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData>({ const [highlightData, setHighlightData] = useState<HighlightData>({
sourceType: 'nostr', sourceType: 'nostr',
sourceValue: '', sourceValue: ''
description: ''
}) })
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({ const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
isMultipleChoice: false, isMultipleChoice: false,
@ -205,7 +204,8 @@ export default function PostContent({
text, text,
highlightData.sourceType, highlightData.sourceType,
highlightData.sourceValue, highlightData.sourceValue,
highlightData.description, highlightData.context,
undefined, // description parameter (not used)
{ {
addClientTag, addClientTag,
isNsfw isNsfw

11
src/lib/draft-event.ts

@ -885,6 +885,7 @@ export async function createHighlightDraftEvent(
highlightedText: string, highlightedText: string,
sourceType: 'nostr' | 'url', sourceType: 'nostr' | 'url',
sourceValue: string, sourceValue: string,
context?: string, // The full text/quote that the highlight is from
description?: string, description?: string,
options?: { options?: {
addClientTag?: boolean addClientTag?: boolean
@ -969,10 +970,14 @@ export async function createHighlightDraftEvent(
tags.push(['r', sourceValue, 'source']) tags.push(['r', sourceValue, 'source'])
} }
// Add context tag if provided (user's comment about the highlight) // Add context tag if provided (the full text/quote that the highlight is from)
// NIP-84 specifies using 'context' for additional context around the highlight if (context && context.trim()) {
tags.push(['context', context.trim()])
}
// Add description tag if provided (user's explanation/comment)
if (description && description.trim()) { if (description && description.trim()) {
tags.push(['context', description.trim()]) tags.push(['description', description.trim()])
} }
// Add p-tag for the author of the source material (if we can determine it) // Add p-tag for the author of the source material (if we can determine it)

Loading…
Cancel
Save