From 079a2f90ef768b2193186bbdade7c22e348ee2a2 Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 18 Dec 2025 21:53:07 +0800 Subject: [PATCH] feat: add support for publishing highlights --- src/components/Content/index.tsx | 177 ++++++++++-------- src/components/HighlightButton/index.tsx | 115 ++++++++++++ src/components/Note/Highlight.tsx | 2 +- src/components/Note/LongFormArticle/index.tsx | 112 ++++++----- src/components/Note/index.tsx | 2 +- .../HighlightNotification.tsx | 26 +++ .../NotificationItem/index.tsx | 4 + src/components/NotificationList/index.tsx | 2 + src/components/PostEditor/PostContent.tsx | 114 ++++++++--- .../PostEditor/PostTextarea/index.tsx | 5 +- src/components/PostEditor/index.tsx | 13 +- src/i18n/locales/ar.ts | 6 +- src/i18n/locales/de.ts | 7 +- src/i18n/locales/en.ts | 6 +- src/i18n/locales/es.ts | 7 +- src/i18n/locales/fa.ts | 6 +- src/i18n/locales/fr.ts | 6 +- src/i18n/locales/hi.ts | 6 +- src/i18n/locales/hu.ts | 6 +- src/i18n/locales/it.ts | 7 +- src/i18n/locales/ja.ts | 7 +- src/i18n/locales/ko.ts | 6 +- src/i18n/locales/pl.ts | 7 +- src/i18n/locales/pt-BR.ts | 7 +- src/i18n/locales/pt-PT.ts | 7 +- src/i18n/locales/ru.ts | 6 +- src/i18n/locales/th.ts | 6 +- src/i18n/locales/zh.ts | 6 +- src/lib/draft-event.ts | 68 +++++++ 29 files changed, 578 insertions(+), 171 deletions(-) create mode 100644 src/components/HighlightButton/index.tsx create mode 100644 src/components/NotificationList/NotificationItem/HighlightNotification.tsx diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 22e1545..72c534a 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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(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 ( -
- {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 ( - - ) - } - if (node.type === 'media') { - return ( - - ) - } - if (node.type === 'url') { - return - } - if (node.type === 'invoice') { - return - } - if (node.type === 'websocket-url') { - return - } - if (node.type === 'event') { - const id = node.data.split(':')[1] - return - } - if (node.type === 'mention') { - return - } - if (node.type === 'hashtag') { - return - } - if (node.type === 'emoji') { - const shortcode = node.data.split(':')[1] - const emoji = emojiInfos.find((e) => e.shortcode === shortcode) - if (!emoji) return node.data - return - } - if (node.type === 'youtube') { - return ( - - ) - } - if (node.type === 'x-post') { - return ( - - ) - } - return null - })} - {lastNormalUrl && } -
+ <> +
+ {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 ( + + ) + } + if (node.type === 'media') { + return ( + + ) + } + if (node.type === 'url') { + return + } + if (node.type === 'invoice') { + return + } + if (node.type === 'websocket-url') { + return + } + if (node.type === 'event') { + const id = node.data.split(':')[1] + return + } + if (node.type === 'mention') { + return + } + if (node.type === 'hashtag') { + return + } + if (node.type === 'emoji') { + const shortcode = node.data.split(':')[1] + const emoji = emojiInfos.find((e) => e.shortcode === shortcode) + if (!emoji) return node.data + return + } + if (node.type === 'youtube') { + return ( + + ) + } + if (node.type === 'x-post') { + return ( + + ) + } + return null + })} + {lastNormalUrl && } +
+ {enableHighlight && ( + + )} + {enableHighlight && ( + + )} + ) } diff --git a/src/components/HighlightButton/index.tsx b/src/components/HighlightButton/index.tsx new file mode 100644 index 0000000..3406be6 --- /dev/null +++ b/src/components/HighlightButton/index.tsx @@ -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 +} + +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(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 ( +
+ +
+ ) +} diff --git a/src/components/Note/Highlight.tsx b/src/components/Note/Highlight.tsx index 6b9ef30..5f68249 100644 --- a/src/components/Note/Highlight.tsx +++ b/src/components/Note/Highlight.tsx @@ -22,7 +22,7 @@ export default function Highlight({ event, className }: { event: Event; classNam return (
- {comment && } + {comment && }
diff --git a/src/components/Note/LongFormArticle/index.tsx b/src/components/Note/LongFormArticle/index.tsx index ce40e91..0b317c9 100644 --- a/src/components/Note/LongFormArticle/index.tsx +++ b/src/components/Note/LongFormArticle/index.tsx @@ -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(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 ( -
-

{metadata.title}

- {metadata.summary && ( -
-

{metadata.summary}

-
- )} - {metadata.image && ( - - )} - { - if (url.startsWith('nostr:')) { - return url.slice(6) // Remove 'nostr:' prefix for rendering - } - return url - }} - components={components} + <> +
- {event.content} - - {metadata.tags.length > 0 && ( -
- {metadata.tags.map((tag) => ( -
{ - e.stopPropagation() - push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) - }} - > - #{tag} -
- ))} -
- )} -
+

{metadata.title}

+ {metadata.summary && ( +
+

{metadata.summary}

+
+ )} + {metadata.image && ( + + )} + { + if (url.startsWith('nostr:')) { + return url.slice(6) // Remove 'nostr:' prefix for rendering + } + return url + }} + components={components} + > + {event.content} + + {metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( +
{ + e.stopPropagation() + push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] })) + }} + > + #{tag} +
+ ))} +
+ )} +
+ + + ) } diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 6df9b6d..950c197 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -117,7 +117,7 @@ export default function Note({ } else if (event.kind === ExtendedKind.FOLLOW_PACK) { content = } else { - content = + content = } return ( diff --git a/src/components/NotificationList/NotificationItem/HighlightNotification.tsx b/src/components/NotificationList/NotificationItem/HighlightNotification.tsx new file mode 100644 index 0000000..59b36e2 --- /dev/null +++ b/src/components/NotificationList/NotificationItem/HighlightNotification.tsx @@ -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 ( + } + sender={notification.pubkey} + sentAt={notification.created_at} + targetEvent={notification} + description={t('highlighted your note')} + isNew={isNew} + /> + ) +} diff --git a/src/components/NotificationList/NotificationItem/index.tsx b/src/components/NotificationList/NotificationItem/index.tsx index 05cc6a0..cd68df9 100644 --- a/src/components/NotificationList/NotificationItem/index.tsx +++ b/src/components/NotificationList/NotificationItem/index.tsx @@ -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 } + if (notification.kind === kinds.Highlights) { + return + } return null } diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index a5fc3b0..8a9ad88 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -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, diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index f3b9733..4663466 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -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 && (
- + {highlightedText ? ( +
+
+
{highlightedText}
+
+ ) : ( + + )}
)} @@ -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 && (
@@ -366,3 +371,62 @@ export default function PostContent({
) } + +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 + }) +} diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 81ac1dc..4ee9c05 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -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({ diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 9668be1..3c3c29b 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -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 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({
- + {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> diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 31822ab..d09616e 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -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': 'نشر التمييز' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index ae72aa7..8d4abed 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 31f36bf..7270639 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 5674cca..095cb87 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index 52dcde2..78a8d84 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -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': 'انتشار برجسته‌سازی' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 242e4bc..9d5031b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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' } } diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index cccd7ee..a3a2933 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -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': 'हाइलाइट प्रकाशित करें' } } diff --git a/src/i18n/locales/hu.ts b/src/i18n/locales/hu.ts index c2f94a5..b531180 100644 --- a/src/i18n/locales/hu.ts +++ b/src/i18n/locales/hu.ts @@ -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' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 38ff911..84e2310 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -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' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 09456bd..8bd6419 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -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': 'ハイライトを公開' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index d6e5644..9ac6fa9 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -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': '하이라이트 게시' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 7b359c2..575909d 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 0de90ad..7144e12 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -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' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index f732aab..aacfb04 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -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' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index ff17367..b2f8cc7 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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': 'Опубликовать Выделение' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index e031851..2a50c0d 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -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': 'เผยแพร่ไฮไลท์' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 308b8c0..9359112 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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': '发布高亮' } } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index cbf4255..9e3d66d 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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,