feat: support for generic repost

This commit is contained in:
codytseng
2025-11-20 13:34:05 +08:00
parent 14b3fbd496
commit a40d7b0676
11 changed files with 92 additions and 68 deletions

View File

@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'
const KIND_FILTER_OPTIONS = [ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.ShortTextNote, ExtendedKind.COMMENT], label: 'Posts' }, { kindGroup: [kinds.ShortTextNote, ExtendedKind.COMMENT], label: 'Posts' },
{ kindGroup: [kinds.Repost], label: 'Reposts' }, { kindGroup: [kinds.Repost, kinds.GenericRepost], label: 'Reposts' },
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.POLL], label: 'Polls' },

View File

@@ -11,12 +11,14 @@ export default function RepostNoteCard({
event, event,
className, className,
filterMutedNotes = true, filterMutedNotes = true,
pinned = false pinned = false,
reposters
}: { }: {
event: Event event: Event
className?: string className?: string
filterMutedNotes?: boolean filterMutedNotes?: boolean
pinned?: boolean pinned?: boolean
reposters?: string[]
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -42,7 +44,10 @@ export default function RepostNoteCard({
} }
} }
if (eventFromContent && verifyEvent(eventFromContent)) { if (eventFromContent && verifyEvent(eventFromContent)) {
if (eventFromContent.kind === kinds.Repost) { if (
eventFromContent.kind === kinds.Repost ||
eventFromContent.kind === kinds.GenericRepost
) {
return return
} }
client.addEventToCache(eventFromContent) client.addEventToCache(eventFromContent)
@@ -84,7 +89,7 @@ export default function RepostNoteCard({
return ( return (
<MainNoteCard <MainNoteCard
className={className} className={className}
reposters={[event.pubkey]} reposters={reposters?.includes(event.pubkey) ? reposters : [event.pubkey]}
event={targetEvent} event={targetEvent}
pinned={pinned} pinned={pinned}
/> />

View File

@@ -34,13 +34,14 @@ export default function NoteCard({
}, [event, filterMutedNotes, mutePubkeySet]) }, [event, filterMutedNotes, mutePubkeySet])
if (shouldHide) return null if (shouldHide) return null
if (event.kind === kinds.Repost) { if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) {
return ( return (
<RepostNoteCard <RepostNoteCard
event={event} event={event}
className={className} className={className}
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
pinned={pinned} pinned={pinned}
reposters={reposters}
/> />
) )
} }

View File

@@ -11,7 +11,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds, verifyEvent } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19' import { decode } from 'nostr-tools/nip19'
import { import {
forwardRef, forwardRef,
@@ -117,20 +117,28 @@ const NoteList = forwardRef(
const repostersMap = new Map<string, Set<string>>() const repostersMap = new Map<string, Set<string>>()
// Final list of filtered events // Final list of filtered events
const filteredEvents: Event[] = [] const filteredEvents: Event[] = []
const keys: string[] = []
events.slice(0, showCount).forEach((evt) => { events.forEach((evt) => {
const key = getEventKey(evt) const key = getEventKey(evt)
if (keySet.has(key)) return if (keySet.has(key)) return
keySet.add(key) keySet.add(key)
if (shouldHideEvent(evt)) return if (shouldHideEvent(evt)) return
if (hideReplies && isReplyNoteEvent(evt)) return if (hideReplies && isReplyNoteEvent(evt)) return
if (evt.kind !== kinds.Repost) { if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) {
filteredEvents.push(evt) filteredEvents.push(evt)
keys.push(key)
return return
} }
let targetEventKey: string | undefined
let eventFromContent: Event | null = null let eventFromContent: Event | null = null
const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
if (targetTag) {
targetEventKey = getKeyFromTag(targetTag)
} else {
// Attempt to extract the target event from the repost content
if (evt.content) { if (evt.content) {
try { try {
eventFromContent = JSON.parse(evt.content) as Event eventFromContent = JSON.parse(evt.content) as Event
@@ -138,39 +146,19 @@ const NoteList = forwardRef(
eventFromContent = null eventFromContent = null
} }
} }
if (eventFromContent && verifyEvent(eventFromContent)) { if (eventFromContent) {
if (eventFromContent.kind === kinds.Repost) { if (
eventFromContent.kind === kinds.Repost ||
eventFromContent.kind === kinds.GenericRepost
) {
return return
} }
if (shouldHideEvent(eventFromContent)) return if (shouldHideEvent(evt)) return
client.addEventToCache(eventFromContent) targetEventKey = getEventKey(eventFromContent)
const targetSeenOn = client.getSeenEventRelays(eventFromContent.id) }
if (targetSeenOn.length === 0) {
const seenOn = client.getSeenEventRelays(evt.id)
seenOn.forEach((relay) => {
client.trackEventSeenOn(eventFromContent.id, relay)
})
} }
const targetEventKey = getEventKey(eventFromContent)
const reposters = repostersMap.get(targetEventKey)
if (reposters) {
reposters.add(evt.pubkey)
} else {
repostersMap.set(targetEventKey, new Set([evt.pubkey]))
}
// If the target event is not already included, add it now
if (!keySet.has(targetEventKey)) {
filteredEvents.push(eventFromContent)
keySet.add(targetEventKey)
}
return
}
const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e'))
if (targetTag) {
const targetEventKey = getKeyFromTag(targetTag)
if (targetEventKey) { if (targetEventKey) {
// Add to reposters map // Add to reposters map
const reposters = repostersMap.get(targetEventKey) const reposters = repostersMap.get(targetEventKey)
@@ -179,22 +167,25 @@ const NoteList = forwardRef(
} else { } else {
repostersMap.set(targetEventKey, new Set([evt.pubkey])) repostersMap.set(targetEventKey, new Set([evt.pubkey]))
} }
// If the target event is already included, skip adding this repost
if (keySet.has(targetEventKey)) { // If the target event is not already included, add it now
return if (!keySet.has(targetEventKey)) {
}
}
}
// If we can't find the original event, just show the repost itself
filteredEvents.push(evt) filteredEvents.push(evt)
return keys.push(targetEventKey)
keySet.add(targetEventKey)
}
}
}) })
return filteredEvents.map((evt) => { return filteredEvents.map((evt, i) => {
const key = getEventKey(evt) const key = keys[i]
return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) }
}) })
}, [events, showCount, shouldHideEvent, hideReplies]) }, [events, shouldHideEvent, hideReplies])
const slicedNotes = useMemo(() => {
return filteredNotes.slice(0, showCount)
}, [filteredNotes, showCount])
const filteredNewEvents = useMemo(() => { const filteredNewEvents = useMemo(() => {
const keySet = new Set<string>() const keySet = new Set<string>()
@@ -369,7 +360,7 @@ const NoteList = forwardRef(
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)} {pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
{filteredNotes.map(({ key, event, reposters }) => ( {slicedNotes.map(({ key, event, reposters }) => (
<NoteCard <NoteCard
key={key} key={key}
className="w-full" className="w-full"

View File

@@ -51,7 +51,7 @@ export function NotificationItem({
) { ) {
return <MentionNotification notification={notification} isNew={isNew} /> return <MentionNotification notification={notification} isNew={isNew} />
} }
if (notification.kind === kinds.Repost) { if (notification.kind === kinds.Repost || notification.kind === kinds.GenericRepost) {
return <RepostNotification notification={notification} isNew={isNew} /> return <RepostNotification notification={notification} isNew={isNew} />
} }
if (notification.kind === kinds.Zap) { if (notification.kind === kinds.Zap) {

View File

@@ -58,13 +58,14 @@ const NotificationList = forwardRef((_, ref) => {
ExtendedKind.POLL ExtendedKind.POLL
] ]
case 'reactions': case 'reactions':
return [kinds.Reaction, kinds.Repost, ExtendedKind.POLL_RESPONSE] return [kinds.Reaction, kinds.Repost, kinds.GenericRepost, ExtendedKind.POLL_RESPONSE]
case 'zaps': case 'zaps':
return [kinds.Zap] return [kinds.Zap]
default: default:
return [ return [
kinds.ShortTextNote, kinds.ShortTextNote,
kinds.Repost, kinds.Repost,
kinds.GenericRepost,
kinds.Reaction, kinds.Reaction,
kinds.Zap, kinds.Zap,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,

View File

@@ -1,5 +1,6 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useStuffStatsById } from '@/hooks/useStuffStatsById' import { useStuffStatsById } from '@/hooks/useStuffStatsById'
import { getEventKey } from '@/lib/event'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
@@ -19,7 +20,7 @@ export default function RepostList({ event }: { event: Event }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useStuffStatsById(event.id) const noteStats = useStuffStatsById(getEventKey(event))
const filteredReposts = useMemo(() => { const filteredReposts = useMemo(() => {
return (noteStats?.reposts ?? []) return (noteStats?.reposts ?? [])
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey)) .filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))

View File

@@ -88,6 +88,7 @@ export const ExtendedKind = {
export const SUPPORTED_KINDS = [ export const SUPPORTED_KINDS = [
kinds.ShortTextNote, kinds.ShortTextNote,
kinds.Repost, kinds.Repost,
kinds.GenericRepost,
ExtendedKind.PICTURE, ExtendedKind.PICTURE,
ExtendedKind.VIDEO, ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO, ExtendedKind.SHORT_VIDEO,

View File

@@ -118,12 +118,22 @@ export function createRepostDraftEvent(event: Event): TDraftEvent {
const isProtected = isProtectedEvent(event) const isProtected = isProtectedEvent(event)
const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)] const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)]
if (event.kind === kinds.ShortTextNote) {
return {
kind: kinds.Repost,
content: isProtected ? '' : JSON.stringify(event),
tags,
created_at: dayjs().unix()
}
}
tags.push(buildKTag(event.kind))
if (isReplaceableEvent(event.kind)) { if (isReplaceableEvent(event.kind)) {
tags.push(buildATag(event)) tags.push(buildATag(event))
} }
return { return {
kind: kinds.Repost, kind: kinds.GenericRepost,
content: isProtected ? '' : JSON.stringify(event), content: isProtected ? '' : JSON.stringify(event),
tags, tags,
created_at: dayjs().unix() created_at: dayjs().unix()

View File

@@ -23,6 +23,7 @@ import {
TThemeSetting, TThemeSetting,
TTranslationServiceConfig TTranslationServiceConfig
} from '@/types' } from '@/types'
import { kinds } from 'nostr-tools'
class LocalStorageService { class LocalStorageService {
static instance: LocalStorageService static instance: LocalStorageService
@@ -181,10 +182,13 @@ class LocalStorageService {
showKindSet.delete(24236) // remove typo kind showKindSet.delete(24236) // remove typo kind
showKindSet.add(ExtendedKind.ADDRESSABLE_SHORT_VIDEO) showKindSet.add(ExtendedKind.ADDRESSABLE_SHORT_VIDEO)
} }
if (showKindsVersion < 4 && showKindSet.has(kinds.Repost)) {
showKindSet.add(kinds.GenericRepost)
}
this.showKinds = Array.from(showKindSet) this.showKinds = Array.from(showKindSet)
} }
window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '3') window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '4')
this.hideContentMentioningMutedUsers = this.hideContentMentioningMutedUsers =
window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true' window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true'

View File

@@ -107,7 +107,10 @@ class StuffStatsService {
? { ? {
'#e': [event.id], '#e': [event.id],
authors: [pubkey], authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost] kinds:
event.kind === kinds.ShortTextNote
? [kinds.Reaction, kinds.Repost]
: [kinds.Reaction, kinds.Repost, kinds.GenericRepost]
} }
: { : {
'#i': [externalContent], '#i': [externalContent],
@@ -120,7 +123,7 @@ class StuffStatsService {
filters.push({ filters.push({
'#a': [replaceableCoordinate], '#a': [replaceableCoordinate],
authors: [pubkey], authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost] kinds: [kinds.Reaction, kinds.Repost, kinds.GenericRepost]
}) })
} }
@@ -218,7 +221,7 @@ class StuffStatsService {
targetKey = this.addLikeByEvent(evt) targetKey = this.addLikeByEvent(evt)
} else if (evt.kind === ExtendedKind.EXTERNAL_CONTENT_REACTION) { } else if (evt.kind === ExtendedKind.EXTERNAL_CONTENT_REACTION) {
targetKey = this.addExternalContentLikeByEvent(evt) targetKey = this.addExternalContentLikeByEvent(evt)
} else if (evt.kind === kinds.Repost) { } else if (evt.kind === kinds.Repost || evt.kind === kinds.GenericRepost) {
targetKey = this.addRepostByEvent(evt) targetKey = this.addRepostByEvent(evt)
} else if (evt.kind === kinds.Zap) { } else if (evt.kind === kinds.Zap) {
targetKey = this.addZapByEvent(evt) targetKey = this.addZapByEvent(evt)
@@ -291,18 +294,25 @@ class StuffStatsService {
} }
private addRepostByEvent(evt: Event) { private addRepostByEvent(evt: Event) {
const eventId = evt.tags.find(tagNameEquals('e'))?.[1] let targetEventKey
if (!eventId) return targetEventKey = evt.tags.find(tagNameEquals('a'))?.[1]
if (!targetEventKey) {
targetEventKey = evt.tags.find(tagNameEquals('e'))?.[1]
}
const old = this.stuffStatsMap.get(eventId) || {} if (!targetEventKey) {
return
}
const old = this.stuffStatsMap.get(targetEventKey) || {}
const repostPubkeySet = old.repostPubkeySet || new Set() const repostPubkeySet = old.repostPubkeySet || new Set()
const reposts = old.reposts || [] const reposts = old.reposts || []
if (repostPubkeySet.has(evt.pubkey)) return if (repostPubkeySet.has(evt.pubkey)) return
repostPubkeySet.add(evt.pubkey) repostPubkeySet.add(evt.pubkey)
reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.stuffStatsMap.set(eventId, { ...old, repostPubkeySet, reposts }) this.stuffStatsMap.set(targetEventKey, { ...old, repostPubkeySet, reposts })
return eventId return targetEventKey
} }
private addZapByEvent(evt: Event) { private addZapByEvent(evt: Event) {