feat: highlight (#346)

This commit is contained in:
Cody Tseng
2025-05-22 22:39:13 +08:00
committed by GitHub
parent ef0dc9e923
commit 6c91ba9eff
8 changed files with 197 additions and 29 deletions

View File

@@ -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

View 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>
)
}

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View File

@@ -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
}
}

View File

@@ -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}`

View File

@@ -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] })

View File

@@ -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>
) )
}) })