feat: add support for publishing highlights

This commit is contained in:
codytseng 2025-12-18 21:53:07 +08:00
parent c4881e3435
commit 079a2f90ef
29 changed files with 578 additions and 171 deletions

View file

@ -15,7 +15,7 @@ import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useMemo, useRef, useState } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
@ -25,8 +25,10 @@ import {
} from '../Embedded'
import Emoji from '../Emoji'
import ExternalLink from '../ExternalLink'
import HighlightButton from '../HighlightButton'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import PostEditor from '../PostEditor'
import WebPreview from '../WebPreview'
import XEmbeddedPost from '../XEmbeddedPost'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
@ -35,13 +37,18 @@ export default function Content({
event,
content,
className,
mustLoadMedia
mustLoadMedia,
enableHighlight = false
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
enableHighlight?: boolean
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
const [selectedText, setSelectedText] = useState('')
const translatedEvent = useTranslatedEvent(event?.id)
const { nodes, allImages, lastNormalUrl, emojiInfos } = useMemo(() => {
const _content = translatedEvent?.content ?? event?.content ?? content
@ -95,81 +102,99 @@ export default function Content({
return null
}
const handleHighlight = (text: string) => {
setSelectedText(text)
setShowHighlightEditor(true)
}
let imageIndex = 0
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
)
}
if (node.type === 'url') {
return <ExternalLink url={node.data} key={index} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
<>
<div ref={contentRef} className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
}
if (node.type === 'image' || node.type === 'images') {
const start = imageIndex
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
start={start}
end={end}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'media') {
return (
<MediaPlayer className="mt-2" key={index} src={node.data} mustLoad={mustLoadMedia} />
)
}
if (node.type === 'url') {
return <ExternalLink url={node.data} key={index} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'x-post') {
return (
<XEmbeddedPost
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
{enableHighlight && (
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
)}
{enableHighlight && (
<PostEditor
highlightedText={selectedText}
parentStuff={event}
open={showHighlightEditor}
setOpen={setShowHighlightEditor}
/>
)}
</>
)
}

View file

@ -0,0 +1,115 @@
import { Button } from '@/components/ui/button'
import { Highlighter } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface HighlightButtonProps {
onHighlight: (selectedText: string) => void
containerRef?: React.RefObject<HTMLElement>
}
export default function HighlightButton({ onHighlight, containerRef }: HighlightButtonProps) {
const { t } = useTranslation()
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
const [selectedText, setSelectedText] = useState('')
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const handleSelectionEnd = () => {
// Use a small delay to ensure selection is complete
setTimeout(() => {
const selection = window.getSelection()
const text = selection?.toString().trim()
if (!text || text.length === 0) {
setPosition(null)
setSelectedText('')
return
}
// Check if selection is within the container (if provided)
if (containerRef?.current) {
const range = selection?.getRangeAt(0)
if (range && !containerRef.current.contains(range.commonAncestorContainer)) {
setPosition(null)
setSelectedText('')
return
}
}
const range = selection?.getRangeAt(0)
if (!range) return
// Get the bounding rect of the entire selection
const rect = range.getBoundingClientRect()
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft
// Position button above the selection area, centered horizontally
setPosition({
top: rect.top + scrollTop - 48, // 48px above the selection
left: rect.left + scrollLeft + rect.width / 2 // Center of the selection
})
setSelectedText(text)
}, 10)
}
// Only listen to mouseup and touchend (when user finishes selection)
document.addEventListener('mouseup', handleSelectionEnd)
document.addEventListener('touchend', handleSelectionEnd)
return () => {
document.removeEventListener('mouseup', handleSelectionEnd)
document.removeEventListener('touchend', handleSelectionEnd)
}
}, [containerRef])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
const selection = window.getSelection()
if (!selection?.toString().trim()) {
setPosition(null)
setSelectedText('')
}
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
if (!position || !selectedText) {
return null
}
return (
<div
className="fixed z-50 animate-in fade-in-0 slide-in-from-bottom-4 duration-200"
style={{
top: `${position.top}px`,
left: `${position.left}px`
}}
>
<Button
ref={buttonRef}
size="sm"
variant="default"
className="shadow-lg gap-2 -translate-x-1/2"
onClick={(e) => {
e.stopPropagation()
onHighlight(selectedText)
// Clear selection after highlighting
window.getSelection()?.removeAllRanges()
setPosition(null)
setSelectedText('')
}}
>
<Highlighter className="h-4 w-4" />
{t('Highlight')}
</Button>
</div>
)
}

View file

@ -22,7 +22,7 @@ export default function Highlight({ event, className }: { event: Event; classNam
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
{comment && <Content event={createFakeEvent({ content: comment })} />}
{comment && <Content event={createFakeEvent({ content: comment, tags: event.tags })} />}
<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">

View file

@ -1,10 +1,12 @@
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import HighlightButton from '@/components/HighlightButton'
import PostEditor from '@/components/PostEditor'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList, toProfile } from '@/lib/link'
import { ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useMemo, useRef, useState } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import NostrNode from './NostrNode'
@ -20,6 +22,14 @@ export default function LongFormArticle({
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
const [showHighlightEditor, setShowHighlightEditor] = useState(false)
const [selectedText, setSelectedText] = useState('')
const handleHighlight = (text: string) => {
setSelectedText(text)
setShowHighlightEditor(true)
}
const components = useMemo(
() =>
@ -74,54 +84,64 @@ export default function LongFormArticle({
/>
)
}) as Components,
[]
[event.pubkey]
)
return (
<div
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
>
<h1 className="break-words">{metadata.title}</h1>
{metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-[3/1] object-cover my-0"
/>
)}
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
<>
<div
ref={contentRef}
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
>
{event.content}
</Markdown>
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
</div>
<h1 className="break-words">{metadata.title}</h1>
{metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full aspect-[3/1] object-cover my-0"
/>
)}
<Markdown
remarkPlugins={[remarkGfm, remarkNostr]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{event.content}
</Markdown>
{metadata.tags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{metadata.tags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
</div>
<HighlightButton onHighlight={handleHighlight} containerRef={contentRef} />
<PostEditor
highlightedText={selectedText}
parentStuff={event}
open={showHighlightEditor}
setOpen={setShowHighlightEditor}
/>
</>
)
}

View file

@ -117,7 +117,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.FOLLOW_PACK) {
content = <FollowPack className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
content = <Content className="mt-2" event={event} enableHighlight />
}
return (

View file

@ -0,0 +1,26 @@
import { Highlighter } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import Notification from './Notification'
export function HighlightNotification({
notification,
isNew = false
}: {
notification: Event
isNew?: boolean
}) {
const { t } = useTranslation()
return (
<Notification
notificationId={notification.id}
icon={<Highlighter size={24} className="text-orange-400" />}
sender={notification.pubkey}
sentAt={notification.created_at}
targetEvent={notification}
description={t('highlighted your note')}
isNew={isNew}
/>
)
}

View file

@ -6,6 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { HighlightNotification } from './HighlightNotification'
import { MentionNotification } from './MentionNotification'
import { PollResponseNotification } from './PollResponseNotification'
import { ReactionNotification } from './ReactionNotification'
@ -60,5 +61,8 @@ export function NotificationItem({
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
return <PollResponseNotification notification={notification} isNew={isNew} />
}
if (notification.kind === kinds.Highlights) {
return <HighlightNotification notification={notification} isNew={isNew} />
}
return null
}

View file

@ -55,6 +55,7 @@ const NotificationList = forwardRef((_, ref) => {
case 'mentions':
return [
kinds.ShortTextNote,
kinds.Highlights,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL
@ -70,6 +71,7 @@ const NotificationList = forwardRef((_, ref) => {
kinds.GenericRepost,
kinds.Reaction,
kinds.Zap,
kinds.Highlights,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT,

View file

@ -1,8 +1,10 @@
import Note from '@/components/Note'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BIG_RELAY_URLS } from '@/constants'
import {
createCommentDraftEvent,
createHighlightDraftEvent,
createPollDraftEvent,
createShortTextNoteDraftEvent,
deleteDraftEventCache
@ -24,18 +26,19 @@ import PostOptions from './PostOptions'
import PostRelaySelector from './PostRelaySelector'
import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
import Uploader from './Uploader'
import { BIG_RELAY_URLS } from '@/constants'
export default function PostContent({
defaultContent = '',
parentStuff,
close,
openFrom
openFrom,
highlightedText
}: {
defaultContent?: string
parentStuff?: Event | string
close: () => void
openFrom?: string[]
highlightedText?: string
}) {
const { t } = useTranslation()
const { pubkey, publish, checkLogin } = useNostr()
@ -68,7 +71,7 @@ export default function PostContent({
const canPost = useMemo(() => {
return (
!!pubkey &&
!!text &&
(!!text || !!highlightedText) &&
!posting &&
!uploadProgresses.length &&
(!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2) &&
@ -77,6 +80,7 @@ export default function PostContent({
}, [
pubkey,
text,
highlightedText,
posting,
uploadProgresses,
isPoll,
@ -123,30 +127,23 @@ export default function PostContent({
const post = async (e?: React.MouseEvent) => {
e?.stopPropagation()
checkLogin(async () => {
if (!canPost || postingRef.current) return
if (!canPost || !pubkey || postingRef.current) return
postingRef.current = true
setPosting(true)
try {
const draftEvent =
parentStuff &&
(typeof parentStuff === 'string' || parentStuff.kind !== kinds.ShortTextNote)
? await createCommentDraftEvent(text, parentStuff, mentions, {
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
: isPoll
? await createPollDraftEvent(pubkey!, text, mentions, pollCreateData, {
addClientTag,
isNsfw
})
: await createShortTextNoteDraftEvent(text, mentions, {
parentEvent,
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
const draftEvent = await createDraftEvent({
parentStuff,
highlightedText,
text,
mentions,
isPoll,
pollCreateData,
pubkey,
addClientTag,
isProtectedEvent,
isNsfw
})
const _additionalRelayUrls = [...additionalRelayUrls]
if (parentStuff && typeof parentStuff === 'string') {
@ -205,7 +202,14 @@ export default function PostContent({
{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 />
{highlightedText ? (
<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">{highlightedText}</div>
</div>
) : (
<Note size="small" event={parentEvent} hideParentNotePreview />
)}
</div>
</ScrollArea>
)}
@ -220,6 +224,7 @@ export default function PostContent({
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadEnd={handleUploadEnd}
placeholder={highlightedText ? t('Write your thoughts about this highlight...') : undefined}
/>
{isPoll && (
<PollEditor
@ -332,7 +337,7 @@ export default function PostContent({
</Button>
<Button type="submit" disabled={!canPost} onClick={post}>
{posting && <LoaderCircle className="animate-spin" />}
{parentStuff ? t('Reply') : t('Post')}
{parentStuff ? (highlightedText ? t('Publish Highlight') : t('Reply')) : t('Post')}
</Button>
</div>
</div>
@ -366,3 +371,62 @@ export default function PostContent({
</div>
)
}
async function createDraftEvent({
parentStuff,
text,
mentions,
isPoll,
pollCreateData,
pubkey,
addClientTag,
isProtectedEvent,
isNsfw,
highlightedText
}: {
parentStuff: Event | string | undefined
text: string
mentions: string[]
isPoll: boolean
pollCreateData: TPollCreateData
pubkey: string
addClientTag: boolean
isProtectedEvent: boolean
isNsfw: boolean
highlightedText?: string
}) {
const { parentEvent, externalContent } =
typeof parentStuff === 'string'
? { parentEvent: undefined, externalContent: parentStuff }
: { parentEvent: parentStuff, externalContent: undefined }
if (highlightedText && parentEvent) {
return createHighlightDraftEvent(highlightedText, text, parentEvent, mentions, {
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
}
if (parentStuff && (externalContent || parentEvent?.kind !== kinds.ShortTextNote)) {
return await createCommentDraftEvent(text, parentStuff, mentions, {
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
}
if (isPoll) {
return await createPollDraftEvent(pubkey, text, mentions, pollCreateData, {
addClientTag,
isNsfw
})
}
return await createShortTextNoteDraftEvent(text, mentions, {
parentEvent,
addClientTag,
protectedEvent: isProtectedEvent,
isNsfw
})
}

View file

@ -40,6 +40,7 @@ const PostTextarea = forwardRef<
onUploadStart?: (file: File, cancel: () => void) => void
onUploadProgress?: (file: File, progress: number) => void
onUploadEnd?: (file: File) => void
placeholder?: string
}
>(
(
@ -52,7 +53,8 @@ const PostTextarea = forwardRef<
className,
onUploadStart,
onUploadProgress,
onUploadEnd
onUploadEnd,
placeholder
},
ref
) => {
@ -67,6 +69,7 @@ const PostTextarea = forwardRef<
HardBreak,
Placeholder.configure({
placeholder:
placeholder ??
t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')'
}),
Emoji.configure({

View file

@ -17,6 +17,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import postEditor from '@/services/post-editor.service'
import { Event } from 'nostr-tools'
import { Dispatch, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import PostContent from './PostContent'
import Title from './Title'
@ -25,14 +26,17 @@ export default function PostEditor({
parentStuff,
open,
setOpen,
openFrom
openFrom,
highlightedText
}: {
defaultContent?: string
parentStuff?: Event | string
open: boolean
setOpen: Dispatch<boolean>
openFrom?: string[]
highlightedText?: string
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const content = useMemo(() => {
@ -42,9 +46,10 @@ export default function PostEditor({
parentStuff={parentStuff}
close={() => setOpen(false)}
openFrom={openFrom}
highlightedText={highlightedText}
/>
)
}, [])
}, [highlightedText])
if (isSmallScreen) {
return (
@ -64,7 +69,7 @@ export default function PostEditor({
<div className="space-y-4 px-2 py-6">
<SheetHeader>
<SheetTitle className="text-start">
<Title parentStuff={parentStuff} />
{highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
</SheetTitle>
<SheetDescription className="hidden" />
</SheetHeader>
@ -92,7 +97,7 @@ export default function PostEditor({
<div className="space-y-4 px-2 py-6">
<DialogHeader>
<DialogTitle>
<Title parentStuff={parentStuff} />
{highlightedText ? t('Create Highlight') : <Title parentStuff={parentStuff} />}
</DialogTitle>
<DialogDescription className="hidden" />
</DialogHeader>