feat: add support for publishing highlights
This commit is contained in:
parent
c4881e3435
commit
079a2f90ef
29 changed files with 578 additions and 171 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
115
src/components/HighlightButton/index.tsx
Normal file
115
src/components/HighlightButton/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue