Browse Source

display context and handle text highlighting

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

132
src/components/Note/Highlight.tsx

@ -1,132 +0,0 @@ @@ -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>
)
}

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

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import { SecondaryPageLink } from '@/PageManager'
import { Event } from 'nostr-tools'
import { ExternalLink, Highlighter } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { nip19 } from 'nostr-tools'
import { toNote } from '@/lib/link'
@ -13,103 +12,127 @@ export default function Highlight({ @@ -13,103 +12,127 @@ export default function Highlight({
event: Event
className?: string
}) {
const { t } = useTranslation()
try {
const { t } = useTranslation()
// Extract the source (e-tag, a-tag, or r-tag)
const source = useMemo(() => {
// Extract the source (e-tag, a-tag, or r-tag) - simplified without useMemo
let source = null
const eTag = event.tags.find(tag => tag[0] === 'e')
if (eTag) {
const eventId = eTag[1]
return {
source = {
type: 'event' as const,
value: eventId,
bech32: nip19.noteEncode(eventId)
}
}
const aTag = event.tags.find(tag => tag[0] === 'a')
if (aTag) {
const [kind, pubkey, identifier] = aTag[1].split(':')
const relay = aTag[2]
return {
type: 'addressable' as const,
value: aTag[1],
bech32: nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier: identifier || '',
relays: relay ? [relay] : []
})
}
}
const rTag = event.tags.find(tag => tag[0] === 'r' && tag[2] === 'source')
if (rTag) {
return {
type: 'url' as const,
value: rTag[1],
bech32: rTag[1]
} else {
const aTag = event.tags.find(tag => tag[0] === 'a')
if (aTag) {
const [kind, pubkey, identifier] = aTag[1].split(':')
const relay = aTag[2]
source = {
type: 'addressable' as const,
value: aTag[1],
bech32: nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier: identifier || '',
relays: relay ? [relay] : []
})
}
} else {
const rTag = event.tags.find(tag => tag[0] === 'r' && tag[2] === 'source')
if (rTag) {
source = {
type: 'url' as const,
value: rTag[1],
bech32: rTag[1]
}
}
}
}
return null
}, [event.tags])
// Extract the context (optional comment/surrounding context)
const context = useMemo(() => {
// Extract the context (the main quote/full text being highlighted from)
const contextTag = event.tags.find(tag => tag[0] === 'context')
return contextTag?.[1] || ''
}, [event.tags])
const context = contextTag?.[1] || event.content // Default to content if no context
// The event.content is the highlighted portion
const highlightedText = event.content
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="flex items-start gap-3">
<Highlighter className="w-5 h-5 text-yellow-600 dark:text-yellow-500 shrink-0 mt-1" />
return (
<div className={`bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${className || ''}`}>
<div className="flex-1 min-w-0">
{/* Highlighted text */}
{event.content && (
<blockquote className="text-base font-normal mb-3 whitespace-pre-wrap break-words italic">
"{event.content}"
</blockquote>
)}
{/* Full quoted text with highlighted portion */}
{context && (
<div className="text-base font-normal mb-3 whitespace-pre-wrap break-words">
{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>
)}
</div>
)}
{/* 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>
)}
{/* Source link */}
{source && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{t('Source')}:</span>
{source.type === 'url' ? (
<a
href={source.value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline flex items-center gap-1"
>
{source.value.length > 50 ? source.value.substring(0, 50) + '...' : source.value}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<SecondaryPageLink
to={toNote(source.bech32)}
className="text-blue-500 hover:underline font-mono"
>
{source.type === 'event'
? `note1${source.bech32.substring(5, 13)}...`
: `naddr1${source.bech32.substring(6, 14)}...`
}
</SecondaryPageLink>
)}
</div>
)}
{/* Source link */}
{source && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{t('Source')}:</span>
{source.type === 'url' ? (
<a
href={source.value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline flex items-center gap-1"
>
{source.value.length > 50 ? source.value.substring(0, 50) + '...' : source.value}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<SecondaryPageLink
to={toNote(source.bech32)}
className="text-blue-500 hover:underline font-mono"
>
{source.type === 'event'
? `note1${source.bech32.substring(5, 13)}...`
: `naddr1${source.bech32.substring(6, 14)}...`
}
</SecondaryPageLink>
)}
</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>
)
)
}
}

39
src/components/Note/index.tsx

@ -22,6 +22,7 @@ import CommunityDefinition from './CommunityDefinition' @@ -22,6 +22,7 @@ import CommunityDefinition from './CommunityDefinition'
import DiscussionContent from './DiscussionContent'
import GroupMetadata from './GroupMetadata'
import Highlight from './Highlight'
import IValue from './IValue'
import LiveEvent from './LiveEvent'
import LongFormArticle from './LongFormArticle'
@ -62,24 +63,38 @@ export default function Note({ @@ -62,24 +63,38 @@ export default function Note({
const [showMuted, setShowMuted] = useState(false)
let content: React.ReactNode
if (
![
...SUPPORTED_KINDS,
kinds.CommunityDefinition,
kinds.LiveEvent,
ExtendedKind.GROUP_METADATA,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT
].includes(event.kind)
) {
const supportedKindsList = [
...SUPPORTED_KINDS,
kinds.CommunityDefinition,
kinds.LiveEvent,
ExtendedKind.GROUP_METADATA,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST,
ExtendedKind.ZAP_RECEIPT
]
if (!supportedKindsList.includes(event.kind)) {
console.log('Note component - rendering UnknownNote for unsupported kind:', event.kind)
content = <UnknownNote className="mt-2" event={event} />
} else if (mutePubkeySet.has(event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} />
} else if (event.kind === kinds.Highlights) {
content = <Highlight className="mt-2" event={event} />
// Try to render the Highlight component with error boundary
try {
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) {
content = showFull ? (
<LongFormArticle className="mt-2" event={event} />

7
src/components/NoteList/index.tsx

@ -153,10 +153,11 @@ const NoteList = forwardRef( @@ -153,10 +153,11 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
useEffect(() => {
if (!subRequests.length) return
useEffect(() => {
if (!subRequests.length) return
async function init() {
async function init() {
setLoading(true)
setEvents([])
setNewEvents([])

38
src/components/PostEditor/HighlightEditor.tsx

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

24
src/components/PostEditor/PostContent.tsx

@ -62,8 +62,7 @@ export default function PostContent({ @@ -62,8 +62,7 @@ export default function PostContent({
const [isHighlight, setIsHighlight] = useState(false)
const [highlightData, setHighlightData] = useState<HighlightData>({
sourceType: 'nostr',
sourceValue: '',
description: ''
sourceValue: ''
})
const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
isMultipleChoice: false,
@ -201,16 +200,17 @@ export default function PostContent({ @@ -201,16 +200,17 @@ export default function PostContent({
if (isHighlight) {
// For highlights, pass the original sourceValue which contains the full identifier
// The createHighlightDraftEvent function will parse it correctly
draftEvent = await createHighlightDraftEvent(
text,
highlightData.sourceType,
highlightData.sourceValue,
highlightData.description,
{
addClientTag,
isNsfw
}
)
draftEvent = await createHighlightDraftEvent(
text,
highlightData.sourceType,
highlightData.sourceValue,
highlightData.context,
undefined, // description parameter (not used)
{
addClientTag,
isNsfw
}
)
} else if (isPublicMessage) {
draftEvent = await createPublicMessageDraftEvent(text, publicMessageRecipients, {
addClientTag,

11
src/lib/draft-event.ts

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

Loading…
Cancel
Save