feat: highlight (#346)
This commit is contained in:
@@ -16,10 +16,12 @@ import Emoji from '../Emoji'
|
|||||||
|
|
||||||
export default function ContentPreview({
|
export default function ContentPreview({
|
||||||
event,
|
event,
|
||||||
className
|
className,
|
||||||
|
onClick
|
||||||
}: {
|
}: {
|
||||||
event?: Event
|
event?: Event
|
||||||
className?: string
|
className?: string
|
||||||
|
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const nodes = useMemo(() => {
|
const nodes = useMemo(() => {
|
||||||
@@ -37,7 +39,7 @@ export default function ContentPreview({
|
|||||||
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
|
const emojiInfos = extractEmojiInfosFromTags(event?.tags)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('pointer-events-none', className)}>
|
<div className={cn('pointer-events-none', className)} onClick={onClick}>
|
||||||
{nodes.map((node, index) => {
|
{nodes.map((node, index) => {
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
return node.data
|
return node.data
|
||||||
|
|||||||
129
src/components/Note/Highlight.tsx
Normal file
129
src/components/Note/Highlight.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useFetchEvent } from '@/hooks'
|
||||||
|
import { createFakeEvent, isSupportedKind } from '@/lib/event'
|
||||||
|
import { toNjump, toNote } from '@/lib/link'
|
||||||
|
import { isValidPubkey } from '@/lib/pubkey'
|
||||||
|
import { generateEventIdFromATag } from '@/lib/tag'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import Content from '../Content'
|
||||||
|
import ContentPreview from '../ContentPreview'
|
||||||
|
import UserAvatar from '../UserAvatar'
|
||||||
|
|
||||||
|
export default function Highlight({ event, className }: { event: Event; className?: string }) {
|
||||||
|
const comment = useMemo(() => event.tags.find((tag) => tag[0] === 'comment')?.[1], [event])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
|
||||||
|
{comment && <Content event={createFakeEvent({ content: comment })} />}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
|
||||||
|
<div className="italic whitespace-pre-line">{event.content}</div>
|
||||||
|
</div>
|
||||||
|
<HighlightSource event={event} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightSource({ event }: { event: Event }) {
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const sourceTag = useMemo(() => {
|
||||||
|
let sourceTag: string[] | undefined
|
||||||
|
for (const tag of event.tags) {
|
||||||
|
if (tag[2] === 'source') {
|
||||||
|
sourceTag = tag
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (tag[0] === 'r') {
|
||||||
|
sourceTag = tag
|
||||||
|
continue
|
||||||
|
} else if (tag[0] === 'a') {
|
||||||
|
if (!sourceTag || sourceTag[0] !== 'r') {
|
||||||
|
sourceTag = tag
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
} else if (tag[0] === 'e') {
|
||||||
|
if (!sourceTag || sourceTag[0] === 'e') {
|
||||||
|
sourceTag = tag
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceTag
|
||||||
|
}, [event])
|
||||||
|
const { event: referenceEvent } = useFetchEvent(
|
||||||
|
sourceTag && sourceTag[0] === 'e' ? sourceTag[1] : undefined
|
||||||
|
)
|
||||||
|
const referenceEventId = useMemo(() => {
|
||||||
|
if (!sourceTag || sourceTag[0] === 'r') return
|
||||||
|
if (sourceTag[0] === 'e') {
|
||||||
|
return sourceTag[1]
|
||||||
|
}
|
||||||
|
if (sourceTag[0] === 'a') {
|
||||||
|
return generateEventIdFromATag(sourceTag)
|
||||||
|
}
|
||||||
|
}, [sourceTag])
|
||||||
|
const pubkey = useMemo(() => {
|
||||||
|
if (referenceEvent) {
|
||||||
|
return referenceEvent.pubkey
|
||||||
|
}
|
||||||
|
if (sourceTag && sourceTag[0] === 'a') {
|
||||||
|
const [, pubkey] = sourceTag[1].split(':')
|
||||||
|
if (isValidPubkey(pubkey)) {
|
||||||
|
return pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sourceTag, referenceEvent])
|
||||||
|
|
||||||
|
if (!sourceTag) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceTag[0] === 'r') {
|
||||||
|
return (
|
||||||
|
<div className="truncate text-muted-foreground">
|
||||||
|
{'From '}
|
||||||
|
<a
|
||||||
|
href={sourceTag[1]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{sourceTag[1]}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<div>{'From'}</div>
|
||||||
|
{pubkey && <UserAvatar userId={pubkey} size="xSmall" className="cursor-pointer" />}
|
||||||
|
{referenceEvent && isSupportedKind(referenceEvent.kind) ? (
|
||||||
|
<ContentPreview
|
||||||
|
className="truncate underline pointer-events-auto cursor-pointer hover:text-foreground"
|
||||||
|
event={referenceEvent}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toNote(referenceEvent))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : referenceEventId ? (
|
||||||
|
<div className="truncate text-muted-foreground">
|
||||||
|
<a
|
||||||
|
href={toNjump(referenceEventId)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{toNjump(referenceEventId)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { ExtendedKind } from '@/constants'
|
import {
|
||||||
import { extractImageInfosFromEventTags, getParentEventId, getUsingClient } from '@/lib/event'
|
extractImageInfosFromEventTags,
|
||||||
|
getParentEventId,
|
||||||
|
getUsingClient,
|
||||||
|
isPictureEvent
|
||||||
|
} from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import Content from '../Content'
|
import Content from '../Content'
|
||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
@@ -11,6 +15,7 @@ import NoteOptions from '../NoteOptions'
|
|||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import Highlight from './Highlight'
|
||||||
|
|
||||||
export default function Note({
|
export default function Note({
|
||||||
event,
|
event,
|
||||||
@@ -25,10 +30,16 @@ export default function Note({
|
|||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const parentEventId = useMemo(
|
const parentEventId = useMemo(
|
||||||
() => (hideParentNotePreview ? undefined : getParentEventId(event)),
|
() =>
|
||||||
|
!hideParentNotePreview && event.kind === kinds.ShortTextNote
|
||||||
|
? getParentEventId(event)
|
||||||
|
: undefined,
|
||||||
[event, hideParentNotePreview]
|
[event, hideParentNotePreview]
|
||||||
)
|
)
|
||||||
const imageInfos = useMemo(() => extractImageInfosFromEventTags(event), [event])
|
const imageInfos = useMemo(
|
||||||
|
() => (isPictureEvent(event) ? extractImageInfosFromEventTags(event) : []),
|
||||||
|
[event]
|
||||||
|
)
|
||||||
const usingClient = useMemo(() => getUsingClient(event), [event])
|
const usingClient = useMemo(() => getUsingClient(event), [event])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,10 +77,12 @@ export default function Note({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Content className="mt-2" event={event} />
|
{event.kind === kinds.Highlights ? (
|
||||||
{event.kind === ExtendedKind.PICTURE && imageInfos.length > 0 && (
|
<Highlight className="mt-2" event={event} />
|
||||||
<ImageGallery images={imageInfos} />
|
) : (
|
||||||
|
<Content className="mt-2" event={event} />
|
||||||
)}
|
)}
|
||||||
|
{imageInfos.length > 0 && <ImageGallery images={imageInfos} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import dayjs from 'dayjs'
|
import { createFakeEvent } from '@/lib/event'
|
||||||
import Content from '../Content'
|
import Content from '../Content'
|
||||||
|
|
||||||
export default function Preview({ content }: { content: string }) {
|
export default function Preview({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-3 min-h-52">
|
<Card className="p-3 min-h-52">
|
||||||
<Content
|
<Content event={createFakeEvent({ content })} className="pointer-events-none h-full" />
|
||||||
event={{
|
|
||||||
content,
|
|
||||||
kind: 1,
|
|
||||||
tags: [],
|
|
||||||
created_at: dayjs().unix(),
|
|
||||||
id: '',
|
|
||||||
pubkey: '',
|
|
||||||
sig: ''
|
|
||||||
}}
|
|
||||||
className="pointer-events-none h-full"
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function isProtectedEvent(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isSupportedKind(kind: number) {
|
export function isSupportedKind(kind: number) {
|
||||||
return [kinds.ShortTextNote, ExtendedKind.PICTURE].includes(kind)
|
return [kinds.ShortTextNote, kinds.Highlights, ExtendedKind.PICTURE].includes(kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParentEventTag(event?: Event) {
|
export function getParentEventTag(event?: Event) {
|
||||||
@@ -524,3 +524,16 @@ export function extractEmojiInfosFromTags(tags: string[][] = []) {
|
|||||||
})
|
})
|
||||||
.filter(Boolean) as TEmoji[]
|
.filter(Boolean) as TEmoji[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFakeEvent(event: Partial<Event>): Event {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
kind: 1,
|
||||||
|
pubkey: '',
|
||||||
|
content: '',
|
||||||
|
created_at: 0,
|
||||||
|
tags: [],
|
||||||
|
sig: '',
|
||||||
|
...event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,3 +52,4 @@ export const toZapStreamLiveEvent = (event: Event) => {
|
|||||||
return `https://zap.stream/${getSharableEventId(event)}`
|
return `https://zap.stream/${getSharableEventId(event)}`
|
||||||
}
|
}
|
||||||
export const toChachiChat = (relay: string, d: string) => `https://chachi.chat/${relay}/${d}`
|
export const toChachiChat = (relay: string, d: string) => `https://chachi.chat/${relay}/${d}`
|
||||||
|
export const toNjump = (id: string) => `https://njump.me/${id}`
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ export function generateEventIdFromETag(tag: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateEventIdFromATag(tag: string[]) {
|
||||||
|
try {
|
||||||
|
const [, coordinate, relay] = tag
|
||||||
|
const [kind, pubkey, identifier] = coordinate.split(':')
|
||||||
|
return nip19.naddrEncode({
|
||||||
|
kind: Number(kind),
|
||||||
|
pubkey,
|
||||||
|
identifier,
|
||||||
|
relays: relay ? [relay] : undefined
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function generateEventId(event: Pick<Event, 'id' | 'pubkey'>) {
|
export function generateEventId(event: Pick<Event, 'id' | 'pubkey'>) {
|
||||||
const relay = client.getEventHint(event.id)
|
const relay = client.getEventHint(event.id)
|
||||||
return nip19.neventEncode({ id: event.id, author: event.pubkey, relays: [relay] })
|
return nip19.neventEncode({ id: event.id, author: event.pubkey, relays: [relay] })
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ import NotFoundPage from '../NotFoundPage'
|
|||||||
const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
|
const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { event, isFetching } = useFetchEvent(id)
|
const { event, isFetching } = useFetchEvent(id)
|
||||||
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
const parentEventId = useMemo(
|
||||||
const rootEventId = useMemo(() => getRootEventId(event), [event])
|
() => (event?.kind === kinds.ShortTextNote ? getParentEventId(event) : undefined),
|
||||||
|
[event]
|
||||||
|
)
|
||||||
|
const rootEventId = useMemo(
|
||||||
|
() => (event?.kind === kinds.ShortTextNote ? getRootEventId(event) : undefined),
|
||||||
|
[event]
|
||||||
|
)
|
||||||
|
|
||||||
if (!event && isFetching) {
|
if (!event && isFetching) {
|
||||||
return (
|
return (
|
||||||
@@ -80,11 +86,11 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
|||||||
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
{event.kind === kinds.ShortTextNote ? (
|
{[kinds.ShortTextNote, kinds.Highlights].includes(event.kind) ? (
|
||||||
<ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} />
|
<ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} />
|
||||||
) : isPictureEvent(event) ? (
|
) : (
|
||||||
<Nip22ReplyNoteList key={`nip22-reply-note-list-${event.id}`} event={event} />
|
<Nip22ReplyNoteList key={`nip22-reply-note-list-${event.id}`} event={event} />
|
||||||
) : null}
|
)}
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user