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>

View File

@@ -384,6 +384,7 @@ export default {
'reacted to your note': 'تفاعل مع ملاحظتك',
'reposted your note': 'أعاد نشر ملاحظتك',
'zapped your note': 'زاب ملاحظتك',
'highlighted your note': 'أبرز ملاحظتك',
'zapped you': 'زابك',
'Mark as read': 'تعليم كمقروء',
Report: 'تبليغ',
@@ -583,6 +584,9 @@ export default {
'Special Follow': 'متابعة خاصة',
'Unfollow Special': 'إلغاء المتابعة الخاصة',
'Personal Feeds': 'التدفقات الشخصية',
'Relay Feeds': 'تدفقات الترحيل'
'Relay Feeds': 'تدفقات الترحيل',
'Create Highlight': 'إنشاء تمييز',
'Write your thoughts about this highlight...': 'اكتب أفكارك حول هذا التمييز...',
'Publish Highlight': 'نشر التمييز'
}
}

View File

@@ -393,6 +393,7 @@ export default {
'reacted to your note': 'hat auf Ihre Notiz reagiert',
'reposted your note': 'hat Ihre Notiz geteilt',
'zapped your note': 'hat Ihre Notiz gezappt',
'highlighted your note': 'hat Ihre Notiz hervorgehoben',
'zapped you': 'hat Sie gezappt',
'Mark as read': 'Als gelesen markieren',
Report: 'Melden',
@@ -599,6 +600,10 @@ export default {
'Special Follow': 'Besonders Folgen',
'Unfollow Special': 'Besonders Entfolgen',
'Personal Feeds': 'Persönliche Feeds',
'Relay Feeds': 'Relay-Feeds'
'Relay Feeds': 'Relay-Feeds',
'Create Highlight': 'Markierung Erstellen',
'Write your thoughts about this highlight...':
'Schreiben Sie Ihre Gedanken zu dieser Markierung...',
'Publish Highlight': 'Markierung Veröffentlichen'
}
}

View File

@@ -383,6 +383,7 @@ export default {
'reacted to your note': 'reacted to your note',
'reposted your note': 'reposted your note',
'zapped your note': 'zapped your note',
'highlighted your note': 'highlighted your note',
'zapped you': 'zapped you',
'Mark as read': 'Mark as read',
Report: 'Report',
@@ -586,6 +587,9 @@ export default {
'Special Follow': 'Special Follow',
'Unfollow Special': 'Unfollow Special',
'Personal Feeds': 'Personal Feeds',
'Relay Feeds': 'Relay Feeds'
'Relay Feeds': 'Relay Feeds',
'Create Highlight': 'Create Highlight',
'Write your thoughts about this highlight...': 'Write your thoughts about this highlight...',
'Publish Highlight': 'Publish Highlight'
}
}

View File

@@ -389,6 +389,7 @@ export default {
'reacted to your note': 'reaccionó a tu nota',
'reposted your note': 'reposteó tu nota',
'zapped your note': 'zappeó tu nota',
'highlighted your note': 'destacó tu nota',
'zapped you': 'te zappeó',
'Mark as read': 'Marcar como leído',
Report: 'Reportar',
@@ -595,6 +596,10 @@ export default {
'Special Follow': 'Seguir Especial',
'Unfollow Special': 'Dejar de Seguir Especial',
'Personal Feeds': 'Feeds Personales',
'Relay Feeds': 'Feeds de Relays'
'Relay Feeds': 'Feeds de Relays',
'Create Highlight': 'Crear Resaltado',
'Write your thoughts about this highlight...':
'Escribe tus pensamientos sobre este resaltado...',
'Publish Highlight': 'Publicar Resaltado'
}
}

View File

@@ -385,6 +385,7 @@ export default {
'reacted to your note': 'به یادداشت شما واکنش نشان داد',
'reposted your note': 'یادداشت شما را بازنشر کرد',
'zapped your note': 'یادداشت شما را زپ کرد',
'highlighted your note': 'یادداشت شما را برجسته کرد',
'zapped you': 'شما را زپ کرد',
'Mark as read': 'علامت‌گذاری به عنوان خوانده شده',
Report: 'گزارش',
@@ -589,6 +590,9 @@ export default {
'Special Follow': 'دنبال کردن ویژه',
'Unfollow Special': 'لغو دنبال کردن ویژه',
'Personal Feeds': 'فیدهای شخصی',
'Relay Feeds': 'فیدهای رله'
'Relay Feeds': 'فیدهای رله',
'Create Highlight': 'ایجاد برجسته‌سازی',
'Write your thoughts about this highlight...': 'نظرات خود را درباره این برجسته‌سازی بنویسید...',
'Publish Highlight': 'انتشار برجسته‌سازی'
}
}

View File

@@ -393,6 +393,7 @@ export default {
'reacted to your note': 'a réagi à votre note',
'reposted your note': 'a repartagé votre note',
'zapped your note': 'a zappé votre note',
'highlighted your note': 'a mis en évidence votre note',
'zapped you': 'vous a zappé',
'Mark as read': 'Marquer comme lu',
Report: 'Signaler',
@@ -598,6 +599,9 @@ export default {
'Special Follow': 'Suivre Spécial',
'Unfollow Special': 'Ne Plus Suivre Spécial',
'Personal Feeds': 'Flux Personnels',
'Relay Feeds': 'Flux de Relais'
'Relay Feeds': 'Flux de Relais',
'Create Highlight': 'Créer un Surlignage',
'Write your thoughts about this highlight...': 'Écrivez vos pensées sur ce surlignage...',
'Publish Highlight': 'Publier le Surlignage'
}
}

View File

@@ -388,6 +388,7 @@ export default {
'reacted to your note': 'ने आपके नोट पर प्रतिक्रिया दी',
'reposted your note': 'ने आपके नोट को रीपोस्ट किया',
'zapped your note': 'ने आपके नोट को जैप किया',
'highlighted your note': 'ने आपके नोट को हाइलाइट किया',
'zapped you': 'ने आपको जैप किया',
'Mark as read': 'पढ़ा हुआ मार्क करें',
Report: 'रिपोर्ट करें',
@@ -590,6 +591,9 @@ export default {
'Special Follow': 'विशेष फ़ॉलो',
'Unfollow Special': 'विशेष अनफ़ॉलो',
'Personal Feeds': 'व्यक्तिगत फ़ीड',
'Relay Feeds': 'रिले फ़ीड'
'Relay Feeds': 'रिले फ़ीड',
'Create Highlight': 'हाइलाइट बनाएं',
'Write your thoughts about this highlight...': 'इस हाइलाइट के बारे में अपने विचार लिखें...',
'Publish Highlight': 'हाइलाइट प्रकाशित करें'
}
}

View File

@@ -385,6 +385,7 @@ export default {
'reacted to your note': 'reagált a posztodra',
'reposted your note': 'újraposztolta a posztodat',
'zapped your note': 'zappolta a posztodat',
'highlighted your note': 'kiemelte a posztodat',
'zapped you': 'zappolt téged',
'Mark as read': 'Megjelölés olvasottként',
Report: 'Jelentés',
@@ -584,6 +585,9 @@ export default {
'Special Follow': 'Különleges Követés',
'Unfollow Special': 'Különleges Követés Megszüntetése',
'Personal Feeds': 'Személyes Feedek',
'Relay Feeds': 'Relay Feedek'
'Relay Feeds': 'Relay Feedek',
'Create Highlight': 'Kiemelés Létrehozása',
'Write your thoughts about this highlight...': 'Írd le a gondolataidat erről a kiemelésről...',
'Publish Highlight': 'Kiemelés Közzététele'
}
}

View File

@@ -389,6 +389,7 @@ export default {
'reacted to your note': 'ha reagito alla tua nota',
'reposted your note': 'ha ricondiviso la tua nota',
'zapped your note': 'ha zappato la tua nota',
'highlighted your note': 'ha evidenziato la tua nota',
'zapped you': 'ti ha zappato',
'Mark as read': 'Segna come letto',
Report: 'Segnala',
@@ -594,6 +595,10 @@ export default {
'Special Follow': 'Segui Speciale',
'Unfollow Special': 'Smetti di Seguire Speciale',
'Personal Feeds': 'Feed Personali',
'Relay Feeds': 'Feed di Relay'
'Relay Feeds': 'Feed di Relay',
'Create Highlight': 'Crea Evidenziazione',
'Write your thoughts about this highlight...':
'Scrivi i tuoi pensieri su questa evidenziazione...',
'Publish Highlight': 'Pubblica Evidenziazione'
}
}

View File

@@ -386,6 +386,7 @@ export default {
'reacted to your note': 'あなたのノートにリアクションしました',
'reposted your note': 'あなたのノートをリポストしました',
'zapped your note': 'あなたのノートにザップしました',
'highlighted your note': 'あなたのノートをハイライトしました',
'zapped you': 'あなたにザップしました',
'Mark as read': '既読にする',
Report: '報告',
@@ -589,6 +590,10 @@ export default {
'Special Follow': '特別フォロー',
'Unfollow Special': '特別フォロー解除',
'Personal Feeds': '個人フィード',
'Relay Feeds': 'リレーフィード'
'Relay Feeds': 'リレーフィード',
'Create Highlight': 'ハイライトを作成',
'Write your thoughts about this highlight...':
'このハイライトについての考えを書いてください...',
'Publish Highlight': 'ハイライトを公開'
}
}

View File

@@ -386,6 +386,7 @@ export default {
'reacted to your note': '당신의 노트에 반응했습니다',
'reposted your note': '당신의 노트를 리포스트했습니다',
'zapped your note': '당신의 노트를 잽했습니다',
'highlighted your note': '당신의 노트를 하이라이트했습니다',
'zapped you': '당신을 잽했습니다',
'Mark as read': '읽음으로 표시',
Report: '신고',
@@ -588,6 +589,9 @@ export default {
'Special Follow': '특별 팔로우',
'Unfollow Special': '특별 팔로우 해제',
'Personal Feeds': '개인 피드',
'Relay Feeds': '릴레이 피드'
'Relay Feeds': '릴레이 피드',
'Create Highlight': '하이라이트 만들기',
'Write your thoughts about this highlight...': '이 하이라이트에 대한 생각을 작성하세요...',
'Publish Highlight': '하이라이트 게시'
}
}

View File

@@ -390,6 +390,7 @@ export default {
'reacted to your note': 'zareagował na twój wpis',
'reposted your note': 'repostował twój wpis',
'zapped your note': 'zappował twój wpis',
'highlighted your note': 'wyróżnił twój wpis',
'zapped you': 'zappował cię',
'Mark as read': 'Oznacz jako przeczytane',
Report: 'Zgłoś',
@@ -595,6 +596,10 @@ export default {
'Special Follow': 'Specjalne Śledzenie',
'Unfollow Special': 'Cofnij Specjalne Śledzenie',
'Personal Feeds': 'Osobiste Kanały',
'Relay Feeds': 'Kanały Przekaźników'
'Relay Feeds': 'Kanały Przekaźników',
'Create Highlight': 'Utwórz Podświetlenie',
'Write your thoughts about this highlight...':
'Napisz swoje przemyślenia na temat tego podświetlenia...',
'Publish Highlight': 'Opublikuj Podświetlenie'
}
}

View File

@@ -386,6 +386,7 @@ export default {
'reacted to your note': 'reagiu à sua nota',
'reposted your note': 'republicou sua nota',
'zapped your note': 'zappeou sua nota',
'highlighted your note': 'destacou sua nota',
'zapped you': 'zappeou você',
'Mark as read': 'Marcar como lida',
Report: 'Denunciar',
@@ -590,6 +591,10 @@ export default {
'Special Follow': 'Favoritos',
'Unfollow Special': 'Desfavoritar',
'Personal Feeds': 'Meus feeds',
'Relay Feeds': 'Feeds de relays'
'Relay Feeds': 'Feeds de relays',
'Create Highlight': 'Criar Destaque',
'Write your thoughts about this highlight...':
'Escreva seus pensamentos sobre este destaque...',
'Publish Highlight': 'Publicar Destaque'
}
}

View File

@@ -389,6 +389,7 @@ export default {
'reacted to your note': 'reagiu à sua nota',
'reposted your note': 'republicou a sua nota',
'zapped your note': 'zappeou a sua nota',
'highlighted your note': 'destacou a sua nota',
'zapped you': 'zappeou-o',
'Mark as read': 'Marcar como lida',
Report: 'Denunciar',
@@ -593,6 +594,10 @@ export default {
'Special Follow': 'Seguir Especial',
'Unfollow Special': 'Deixar de Seguir Especial',
'Personal Feeds': 'Feeds Pessoais',
'Relay Feeds': 'Feeds de Relays'
'Relay Feeds': 'Feeds de Relays',
'Create Highlight': 'Criar Destaque',
'Write your thoughts about this highlight...':
'Escreva os seus pensamentos sobre este destaque...',
'Publish Highlight': 'Publicar Destaque'
}
}

View File

@@ -390,6 +390,7 @@ export default {
'reacted to your note': 'отреагировал на вашу заметку',
'reposted your note': 'репостнул вашу заметку',
'zapped your note': 'заппил вашу заметку',
'highlighted your note': 'выделил вашу заметку',
'zapped you': 'заппил вас',
'Mark as read': 'Отметить как прочитанное',
Report: 'Пожаловаться',
@@ -595,6 +596,9 @@ export default {
'Special Follow': 'Особая Подписка',
'Unfollow Special': 'Отменить Особую Подписку',
'Personal Feeds': 'Личные Ленты',
'Relay Feeds': 'Ленты Релеев'
'Relay Feeds': 'Ленты Релеев',
'Create Highlight': 'Создать Выделение',
'Write your thoughts about this highlight...': 'Напишите свои мысли об этом выделении...',
'Publish Highlight': 'Опубликовать Выделение'
}
}

View File

@@ -382,6 +382,7 @@ export default {
'reacted to your note': 'ได้แสดงปฏิกิริยาต่อโน้ตของคุณ',
'reposted your note': 'ได้รีโพสต์โน้ตของคุณ',
'zapped your note': 'ได้แซปโน้ตของคุณ',
'highlighted your note': 'ได้ไฮไลต์โน้ตของคุณ',
'zapped you': 'ได้แซปคุณ',
'Mark as read': 'ทำเครื่องหมายว่าอ่านแล้ว',
Report: 'รายงาน',
@@ -582,6 +583,9 @@ export default {
'Special Follow': 'ติดตามพิเศษ',
'Unfollow Special': 'ยกเลิกติดตามพิเศษ',
'Personal Feeds': 'ฟีดส่วนตัว',
'Relay Feeds': 'ฟีดรีเลย์'
'Relay Feeds': 'ฟีดรีเลย์',
'Create Highlight': 'สร้างไฮไลท์',
'Write your thoughts about this highlight...': 'เขียนความคิดของคุณเกี่ยวกับไฮไลท์นี้...',
'Publish Highlight': 'เผยแพร่ไฮไลท์'
}
}

View File

@@ -380,6 +380,7 @@ export default {
'reacted to your note': '对您的笔记做出了反应',
'reposted your note': '转发了您的笔记',
'zapped your note': '打闪了您的笔记',
'highlighted your note': '高亮了您的笔记',
'zapped you': '给您打闪',
'Mark as read': '标记为已读',
Report: '举报',
@@ -575,6 +576,9 @@ export default {
'Special Follow': '特别关注',
'Unfollow Special': '取消特别关注',
'Personal Feeds': '个人订阅',
'Relay Feeds': '中继订阅'
'Relay Feeds': '中继订阅',
'Create Highlight': '创建高亮',
'Write your thoughts about this highlight...': '写下你对这段高亮的想法...',
'Publish Highlight': '发布高亮'
}
}

View File

@@ -307,6 +307,74 @@ export async function createCommentDraftEvent(
return setDraftEventCache(baseDraft)
}
// https://github.com/nostr-protocol/nips/blob/master/84.md
export function createHighlightDraftEvent(
highlightedText: string,
comment: string = '',
sourceEvent: Event,
mentions: string[],
options: {
addClientTag?: boolean
protectedEvent?: boolean
isNsfw?: boolean
} = {}
): TDraftEvent {
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(comment)
const quoteTags = extractQuoteTags(comment)
const hashtags = extractHashtags(transformedEmojisContent)
const tags = emojiTags.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
// imeta tags
const images = extractImagesFromContent(transformedEmojisContent)
if (images && images.length) {
tags.push(...generateImetaTags(images))
}
// q tags
tags.push(...quoteTags)
// p tags
tags.push(
...mentions
.filter((pubkey) => pubkey !== sourceEvent.pubkey)
.map((pubkey) => ['p', pubkey, '', 'mention'])
)
// Add comment tag if comment exists
if (transformedEmojisContent) {
tags.push(['comment', transformedEmojisContent])
}
// Add source reference
const hint = client.getEventHint(sourceEvent.id)
if (isReplaceableEvent(sourceEvent.kind)) {
tags.push(['a', getReplaceableCoordinateFromEvent(sourceEvent), hint, 'source'])
} else {
tags.push(['e', sourceEvent.id, hint, 'source'])
}
tags.push(['p', sourceEvent.pubkey, '', 'author'])
if (options.addClientTag) {
tags.push(buildClientTag())
}
if (options.isNsfw) {
tags.push(buildNsfwTag())
}
if (options.protectedEvent) {
tags.push(buildProtectedTag())
}
const baseDraft = {
kind: kinds.Highlights,
content: highlightedText,
tags
}
return setDraftEventCache(baseDraft)
}
export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
return {
kind: kinds.RelayList,