feat: add more note interactions lists (#467)

Co-authored-by: Trevor Arjeski <tmarjeski@gmail.com>
This commit is contained in:
Cody Tseng
2025-08-09 00:49:32 +08:00
committed by GitHub
parent da78aa63ef
commit f2c87b8d5f
27 changed files with 654 additions and 156 deletions

View File

@@ -33,103 +33,114 @@ import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
const translatedEvent = useTranslatedEvent(event.id)
const nodes = parseContent(translatedEvent?.content ?? event.content, [
EmbeddedYoutubeParser,
EmbeddedImageParser,
EmbeddedMediaParser,
EmbeddedNormalUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const Content = memo(
({ event, content, className }: { event?: Event; content?: string; className?: string }) => {
const translatedEvent = useTranslatedEvent(event?.id)
const _content = translatedEvent?.content ?? event?.content ?? content
if (!_content) return null
const imageInfos = getImageInfosFromEvent(event)
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
const imageInfo = imageInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag
? getImageInfoFromImetaTag(tag, event.pubkey)
: { url: node.data, pubkey: event.pubkey }
}
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imageInfos.find((image) => image.url === url)
return imageInfo ?? { url, pubkey: event.pubkey }
})
}
return null
})
.filter(Boolean)
.flat() as TImageInfo[]
let imageIndex = 0
const nodes = parseContent(_content, [
EmbeddedYoutubeParser,
EmbeddedImageParser,
EmbeddedMediaParser,
EmbeddedNormalUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
{nodes.map((node, index) => {
if (node.type === 'text') {
return node.data
const imageInfos = event ? getImageInfosFromEvent(event) : []
const allImages = nodes
.map((node) => {
if (node.type === 'image') {
const imageInfo = imageInfos.find((image) => image.url === node.data)
if (imageInfo) {
return imageInfo
}
const tag = mediaUpload.getImetaTagByUrl(node.data)
return tag
? getImageInfoFromImetaTag(tag, event?.pubkey)
: { url: node.data, pubkey: event?.pubkey }
}
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} />
)
}
if (node.type === 'media') {
return <MediaPlayer className="mt-2" key={index} src={node.data} />
}
if (node.type === 'url') {
return <EmbeddedNormalUrl 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 emoji={emoji} key={index} className="size-4" />
}
if (node.type === 'youtube') {
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
return urls.map((url) => {
const imageInfo = imageInfos.find((image) => image.url === url)
return imageInfo ?? { url, pubkey: event?.pubkey }
})
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
)
})
})
.filter(Boolean)
.flat() as TImageInfo[]
let imageIndex = 0
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
const lastNormalUrl =
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
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}
/>
)
}
if (node.type === 'media') {
return <MediaPlayer className="mt-2" key={index} src={node.data} />
}
if (node.type === 'url') {
return <EmbeddedNormalUrl 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 emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
)
}
)
Content.displayName = 'Content'
export default Content

View File

@@ -5,30 +5,35 @@ import { HTMLAttributes, useState } from 'react'
export default function Emoji({
emoji,
className = ''
}: HTMLAttributes<HTMLDivElement> & {
className?: string
classNames
}: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & {
emoji: TEmoji | string
classNames?: {
text?: string
img?: string
}
}) {
const [hasError, setHasError] = useState(false)
if (typeof emoji === 'string') {
return emoji === '+' ? (
<Heart className={cn('size-4 text-red-400 fill-red-400', className)} />
<Heart className={cn('size-4 text-red-400 fill-red-400', classNames?.img)} />
) : (
<span className={cn('whitespace-nowrap', className)}>{emoji}</span>
<span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
)
}
if (hasError) {
return <span className={cn('whitespace-nowrap', className)}>{`:${emoji.shortcode}:`}</span>
return (
<span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span>
)
}
return (
<img
src={emoji.url}
alt={emoji.shortcode}
className={cn('inline-block size-4', className)}
className={cn('inline-block size-4', classNames?.img)}
onLoad={() => {
setHasError(false)
}}

View File

@@ -2,9 +2,12 @@ import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'quotes'
export type TTabValue = 'replies' | 'quotes' | 'reactions' | 'reposts' | 'zaps'
const TABS = [
{ value: 'replies', label: 'Replies' },
{ value: 'zaps', label: 'Zaps' },
{ value: 'reposts', label: 'Reposts' },
{ value: 'reactions', label: 'Reactions' },
{ value: 'quotes', label: 'Quotes' }
] as { value: TTabValue; label: string }[]

View File

@@ -1,9 +1,13 @@
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList'
import RepostList from '../RepostList'
import ZapList from '../ZapList'
import { Tabs, TTabValue } from './Tabs'
export default function NoteInteractions({
@@ -14,19 +18,41 @@ export default function NoteInteractions({
event: Event
}) {
const [type, setType] = useState<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList index={pageIndex} event={event} />
break
case 'quotes':
list = <QuoteList event={event} />
break
case 'reactions':
list = <ReactionList event={event} />
break
case 'reposts':
list = <RepostList event={event} />
break
case 'zaps':
list = <ZapList event={event} />
break
default:
break
}
return (
<>
<div className="flex items-center justify-between pr-1">
<Tabs selectedTab={type} onTabChange={setType} />
<HideUntrustedContentButton type="interactions" />
<div className="flex items-center justify-between">
<ScrollArea className="flex-1 w-0">
<Tabs selectedTab={type} onTabChange={setType} />
<ScrollBar orientation="horizontal" className="opacity-0" />
</ScrollArea>
<Separator orientation="vertical" className="h-6" />
<div className="size-10 flex items-center justify-center">
<HideUntrustedContentButton type="interactions" />
</div>
</div>
<Separator />
{type === 'replies' ? (
<ReplyNoteList index={pageIndex} event={event} />
) : (
<QuoteList event={event} />
)}
{list}
</>
)
}

View File

@@ -22,14 +22,14 @@ export default function TopZaps({ event }: { event: Event }) {
{topZaps.map((zap, index) => (
<div
key={zap.pr}
className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted/80 items-center text-yellow-400 border border-yellow-400 hover:bg-yellow-400/20 cursor-pointer"
className="flex gap-1 py-1 pl-1 pr-2 text-sm max-w-72 rounded-full bg-muted/80 items-center text-yellow-400 border border-yellow-400 hover:bg-yellow-400/20 cursor-pointer"
onClick={(e) => {
e.stopPropagation()
setZapIndex(index)
}}
>
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
<Zap className="size-3 fill-yellow-400" />
<Zap className="size-3 fill-yellow-400 shrink-0" />
<div className="font-semibold">{formatAmount(zap.amount)}</div>
<div className="truncate">{zap.comment}</div>
<div onClick={(e) => e.stopPropagation()}>

View File

@@ -1,4 +1,5 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
@@ -38,7 +39,9 @@ export default function QuoteList({ event, className }: { event: Event; classNam
{
urls: relayUrls,
filter: {
'#q': [event.id],
'#q': [
isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
],
kinds: [
kinds.ShortTextNote,
kinds.Highlights,
@@ -130,7 +133,7 @@ export default function QuoteList({ event, className }: { event: Event; classNam
return (
<div className={className}>
<div className="min-h-screen">
<div className="min-h-[80vh]">
<div>
{events.slice(0, showCount).map((event) => {
if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {

View File

@@ -0,0 +1,89 @@
import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const SHOW_COUNT = 20
export default function ReactionList({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const filteredLikes = useMemo(() => {
return (noteStats?.likes ?? [])
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!bottomRef.current || filteredLikes.length <= showCount) return
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
},
{ rootMargin: '10px', threshold: 0.1 }
)
obs.observe(bottomRef.current)
return () => obs.disconnect()
}, [filteredLikes.length, showCount])
return (
<div className="min-h-[80vh]">
{filteredLikes.slice(0, showCount).map((like) => (
<div
key={like.id}
className="px-4 py-3 border-b transition-colors clickable flex items-center gap-3"
onClick={() => push(toProfile(like.pubkey))}
>
<div className="w-6 flex flex-col items-center">
<Emoji
emoji={like.emoji}
classNames={{
text: 'text-xl',
img: 'size-5'
}}
/>
</div>
<UserAvatar userId={like.pubkey} size="medium" className="shrink-0" />
<div className="flex-1 w-0">
<Username
userId={like.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
skeletonClassName="h-3"
/>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={like.pubkey} append="·" />
<FormattedTimestamp
timestamp={like.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
</div>
))}
<div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground">
{filteredLikes.length > 0 ? t('No more reactions') : t('No reactions yet')}
</div>
</div>
)
}

View File

@@ -274,7 +274,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
}, [])
return (
<div className="min-h-screen">
<div className="min-h-[80vh]">
{loading && (replies.length === 0 ? <ReplyNoteSkeleton /> : <LoadingBar />)}
{!loading && until && (
<div

View File

@@ -0,0 +1,81 @@
import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { Repeat } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const SHOW_COUNT = 20
export default function RepostList({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const noteStats = useNoteStatsById(event.id)
const filteredReposts = useMemo(() => {
return (noteStats?.reposts ?? [])
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))
.sort((a, b) => b.created_at - a.created_at)
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!bottomRef.current || filteredReposts.length <= showCount) return
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
},
{ rootMargin: '10px', threshold: 0.1 }
)
obs.observe(bottomRef.current)
return () => obs.disconnect()
}, [filteredReposts.length, showCount])
return (
<div className="min-h-[80vh]">
{filteredReposts.slice(0, showCount).map((repost) => (
<div
key={repost.id}
className="px-4 py-3 border-b transition-colors clickable flex items-center gap-3"
onClick={() => push(toProfile(repost.pubkey))}
>
<Repeat className="text-green-400 size-5" />
<UserAvatar userId={repost.pubkey} size="medium" className="shrink-0" />
<div className="flex-1 w-0">
<Username
userId={repost.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
skeletonClassName="h-3"
/>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={repost.pubkey} append="·" />
<FormattedTimestamp
timestamp={repost.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
</div>
))}
<div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground">
{filteredReposts.length > 0 ? t('No more reposts') : t('No reposts yet')}
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useSecondaryPage } from '@/PageManager'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
const SHOW_COUNT = 20
export default function ZapList({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const noteStats = useNoteStatsById(event.id)
const filteredZaps = useMemo(() => {
return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount)
}, [noteStats, event.id])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!bottomRef.current || filteredZaps.length <= showCount) return
const obs = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
},
{ rootMargin: '10px', threshold: 0.1 }
)
obs.observe(bottomRef.current)
return () => obs.disconnect()
}, [filteredZaps.length, showCount])
return (
<div className="min-h-[80vh]">
{filteredZaps.slice(0, showCount).map((zap) => (
<div
key={zap.pr}
className="px-4 py-3 border-b transition-colors clickable flex gap-2"
onClick={() => push(toProfile(zap.pubkey))}
>
<div className="w-8 flex flex-col items-center mt-0.5">
<Zap className="text-yellow-400 size-5" />
<div className="text-sm font-semibold text-yellow-400">{formatAmount(zap.amount)}</div>
</div>
<div className="flex space-x-2 items-start">
<UserAvatar userId={zap.pubkey} size="medium" className="shrink-0 mt-0.5" />
<div className="flex-1">
<Username
userId={zap.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
skeletonClassName="h-3"
/>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={zap.pubkey} append="·" />
<FormattedTimestamp
timestamp={zap.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
<Content className="mt-2" content={zap.comment} />
</div>
</div>
</div>
))}
<div ref={bottomRef} />
<div className="text-sm mt-2 text-center text-muted-foreground">
{filteredZaps.length > 0 ? t('No more zaps') : t('No zaps yet')}
</div>
</div>
)
}

View File

@@ -326,6 +326,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'تم البث بنجاح إلى المرحل: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'فشل البث إلى المرحل: {{url}}. خطأ: {{error}}',
'Write relays': 'مرحلات الكتابة'
'Write relays': 'مرحلات الكتابة',
'No more reactions': 'لا توجد تفاعلات إضافية',
'No reactions yet': 'لا توجد تفاعلات بعد',
'No more zaps': 'لا توجد مزيد من الزابس',
'No zaps yet': 'لا توجد زابس بعد',
'No more reposts': 'لا توجد مزيد من إعادة النشر',
'No reposts yet': 'لا توجد إعادة نشر بعد',
Reposts: 'إعادة النشر'
}
}

View File

@@ -333,6 +333,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Erfolgreich an Relay gesendet: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Fehler beim Senden an Relay: {{url}}. Fehler: {{error}}',
'Write relays': 'Schreib-Relays'
'Write relays': 'Schreib-Relays',
'No more reactions': 'Keine weiteren Reaktionen',
'No reactions yet': 'Noch keine Reaktionen',
'No more zaps': 'Keine weiteren Zaps',
'No zaps yet': 'Noch keine Zaps',
'No more reposts': 'Keine weiteren Reposts',
'No reposts yet': 'Noch keine Reposts',
Reposts: 'Reposts'
}
}

View File

@@ -327,6 +327,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Successfully broadcasted to relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Failed to broadcast to relay: {{url}}. Error: {{error}}',
'Write relays': 'Write relays'
'Write relays': 'Write relays',
'No more reactions': 'No more reactions',
'No reactions yet': 'No reactions yet',
'No more zaps': 'No more zaps',
'No zaps yet': 'No zaps yet',
'No more reposts': 'No more reposts',
'No reposts yet': 'No reposts yet',
Reposts: 'Reposts'
}
}

View File

@@ -332,6 +332,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Transmitido exitosamente al relé: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Error al transmitir al relé: {{url}}. Error: {{error}}',
'Write relays': 'Relés de escritura'
'Write relays': 'Relés de escritura',
'No more reactions': 'No hay más reacciones',
'No reactions yet': 'Sin reacciones aún',
'No more zaps': 'No hay más zaps',
'No zaps yet': 'Sin zaps aún',
'No more reposts': 'No hay más reposts',
'No reposts yet': 'Sin reposts aún',
Reposts: 'Reposts'
}
}

View File

@@ -327,6 +327,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'با موفقیت به رله پخش شد: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'پخش به رله ناموفق بود: {{url}}. خطا: {{error}}',
'Write relays': 'رله‌های نوشتن'
'Write relays': 'رله‌های نوشتن',
'No more reactions': 'هیچ واکنشی بیشتر وجود ندارد',
'No reactions yet': 'هنوز هیچ واکنشی وجود ندارد',
'No more zaps': 'هیچ زپی بیشتر وجود ندارد',
'No zaps yet': 'هنوز هیچ زپی وجود ندارد',
'No more reposts': 'هیچ بازنشر بیشتری وجود ندارد',
'No reposts yet': 'هنوز هیچ بازنشر وجود ندارد',
Reposts: 'بازنشرها'
}
}

View File

@@ -332,6 +332,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Diffusion réussie vers le relais : {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Échec de la diffusion vers le relais : {{url}}. Erreur : {{error}}',
'Write relays': 'Relais décriture'
'Write relays': 'Relais décriture',
'No more reactions': 'Plus de réactions',
'No reactions yet': 'Pas encore de réactions',
'No more zaps': 'Plus de zaps',
'No zaps yet': 'Pas encore de zaps',
'No more reposts': 'Plus de reposts',
'No reposts yet': 'Pas encore de reposts',
Reposts: 'Reposts'
}
}

View File

@@ -331,6 +331,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Trasmesso con successo al relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Errore nella trasmissione al relay: {{url}}. Errore: {{error}}',
'Write relays': 'Relay di scrittura'
'Write relays': 'Relay di scrittura',
'No more reactions': 'Non ci sono più reazioni',
'No reactions yet': 'Ancora nessuna reazione',
'No more zaps': 'Non ci sono più zaps',
'No zaps yet': 'Ancora nessuno zap',
'No more reposts': 'Non ci sono più repost',
'No reposts yet': 'Ancora nessun repost',
Reposts: 'Repost'
}
}

View File

@@ -329,6 +329,13 @@ export default {
'リレイへのブロードキャストが成功しました:{{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'リレイへのブロードキャストが失敗しました:{{url}}。エラー:{{error}}',
'Write relays': '書きリレイ'
'Write relays': '書きリレイ',
'No more reactions': 'これ以上の反応はありません',
'No reactions yet': 'まだ反応はありません',
'No more zaps': 'これ以上のZapはありません',
'No zaps yet': 'まだZapはありません',
'No more reposts': 'これ以上のリポストはありません',
'No reposts yet': 'まだリポストはありません',
Reposts: 'リポスト'
}
}

View File

@@ -328,6 +328,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': '릴레이로 브로드캐스트에 성공했습니다: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'릴레이로 브로드캐스트에 실패했습니다: {{url}}. 오류: {{error}}',
'Write relays': '쓰기 릴레이'
'Write relays': '쓰기 릴레이',
'No more reactions': '더 이상 반응이 없습니다',
'No reactions yet': '아직 반응이 없습니다',
'No more zaps': '더 이상 즙이 없습니다',
'No zaps yet': '아직 즙이 없습니다',
'No more reposts': '더 이상 리포스트가 없습니다',
'No reposts yet': '아직 리포스트가 없습니다',
Reposts: '리포스트'
}
}

View File

@@ -331,6 +331,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Pomyślnie transmitowano do przekaźnika: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Nie udało się transmitować do przekaźnika: {{url}}. Błąd: {{error}}',
'Write relays': 'Przekaźniki zapisu'
'Write relays': 'Przekaźniki zapisu',
'No more reactions': 'Brak kolejnych reakcji',
'No reactions yet': 'Brak reakcji',
'No more zaps': 'Brak kolejnych zapów',
'No zaps yet': 'Brak zapów',
'No more reposts': 'Brak kolejnych repostów',
'No reposts yet': 'Brak repostów',
Reposts: 'Reposty'
}
}

View File

@@ -330,6 +330,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Falha ao transmitir para o relay: {{url}}. Erro: {{error}}',
'Write relays': 'Relés de escrita'
'Write relays': 'Relés de escrita',
'No more reactions': 'Sem mais reações',
'No reactions yet': 'Ainda sem reações',
'No more zaps': 'Sem mais zaps',
'No zaps yet': 'Ainda sem zaps',
'No more reposts': 'Sem mais reposts',
'No reposts yet': 'Ainda sem reposts',
Reposts: 'Reposts'
}
}

View File

@@ -331,6 +331,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Falha ao transmitir para o relay: {{url}}. Erro: {{error}}',
'Write relays': 'Relés de escrita'
'Write relays': 'Relés de escrita',
'No more reactions': 'Sem mais reações',
'No reactions yet': 'Ainda sem reações',
'No more zaps': 'Sem mais zaps',
'No zaps yet': 'Ainda sem zaps',
'No more reposts': 'Sem mais reposts',
'No reposts yet': 'Ainda sem reposts',
Reposts: 'Reposts'
}
}

View File

@@ -331,6 +331,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'Успешно транслировано в релей: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'Ошибка трансляции в релей: {{url}}. Ошибка: {{error}}',
'Write relays': 'Ретрансляторы для записи'
'Write relays': 'Ретрансляторы для записи',
'No more reactions': 'Больше нет реакций',
'No reactions yet': 'Пока нет реакций',
'No more zaps': 'Больше нет запов',
'No zaps yet': 'Пока нет запов',
'No more reposts': 'Больше нет репостов',
'No reposts yet': 'Пока нет репостов',
Reposts: 'Репосты'
}
}

View File

@@ -325,6 +325,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': 'ส่งสัญญาณไปยังรีเลย์สำเร็จแล้ว: {{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'การส่งสัญญาณไปยังรีเลย์ล้มเหลว: {{url}} ข้อผิดพลาด: {{error}}',
'Write relays': 'รีเลย์การเขียน'
'Write relays': 'รีเลย์การเขียน',
'No more reactions': 'ไม่มีปฏิกิริยาเพิ่มเติม',
'No reactions yet': 'ยังไม่มีปฏิกิริยา',
'No more zaps': 'ไม่มีซาตส์เพิ่มเติม',
'No zaps yet': 'ยังไม่มีซาตส์',
'No more reposts': 'ไม่มีการรีโพสต์เพิ่มเติม',
'No reposts yet': 'ยังไม่มีการรีโพสต์',
Reposts: 'การรีโพสต์'
}
}

View File

@@ -324,6 +324,13 @@ export default {
'Successfully broadcasted to relay: {{url}}': '成功广播到服务器:{{url}}',
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
'广播到服务器失败:{{url}}。错误:{{error}}',
'Write relays': '写服务器'
'Write relays': '写服务器',
'No more reactions': '没有更多互动了',
'No reactions yet': '暂无互动',
'No more zaps': '没有更多打闪了',
'No zaps yet': '暂无打闪',
'No more reposts': '没有更多转发了',
'No reposts yet': '暂无转发',
Reposts: '转发'
}
}

View File

@@ -1,4 +1,4 @@
import { ApplicationDataKey, ExtendedKind, POLL_TYPE } from '@/constants'
import { ApplicationDataKey, EMBEDDED_EVENT_REGEX, ExtendedKind, POLL_TYPE } from '@/constants'
import client from '@/services/client.service'
import mediaUpload from '@/services/media-upload.service'
import {
@@ -12,6 +12,7 @@ import {
import dayjs from 'dayjs'
import { Event, kinds, nip19 } from 'nostr-tools'
import {
getReplaceableCoordinate,
getReplaceableCoordinateFromEvent,
getRootETag,
isProtectedEvent,
@@ -54,6 +55,10 @@ export function createRepostDraftEvent(event: Event): TDraftEvent {
const isProtected = isProtectedEvent(event)
const tags = [buildETag(event.id, event.pubkey), buildPTag(event.pubkey)]
if (isReplaceableEvent(event.kind)) {
tags.push(buildATag(event))
}
return {
kind: kinds.Repost,
content: isProtected ? '' : JSON.stringify(event),
@@ -73,10 +78,8 @@ export async function createShortTextNoteDraftEvent(
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventIds, rootETag, parentETag } = await extractRelatedEventIds(
content,
options.parentEvent
)
const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } =
await extractRelatedEventIds(content, options.parentEvent)
const hashtags = extractHashtags(content)
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
@@ -88,7 +91,8 @@ export async function createShortTextNoteDraftEvent(
}
// q tags
tags.push(...quoteEventIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
// e tags
if (rootETag.length) {
@@ -153,7 +157,7 @@ export async function createPictureNoteDraftEvent(
protectedEvent?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventIds } = await extractRelatedEventIds(content)
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(content)
const hashtags = extractHashtags(content)
if (!pictureInfos.length) {
throw new Error('No images found in content')
@@ -162,7 +166,8 @@ export async function createPictureNoteDraftEvent(
const tags = pictureInfos
.map((info) => buildImetaTag(info.tags))
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
.concat(quoteEventIds.map((eventId) => buildQTag(eventId)))
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
.concat(mentions.map((pubkey) => buildPTag(pubkey)))
if (options.addClientTag) {
@@ -192,13 +197,21 @@ export async function createCommentDraftEvent(
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventIds, rootEventId, rootCoordinateTag, rootKind, rootPubkey, rootUrl } =
await extractCommentMentions(content, parentEvent)
const {
quoteEventHexIds,
quoteReplaceableCoordinates,
rootEventId,
rootCoordinateTag,
rootKind,
rootPubkey,
rootUrl
} = await extractCommentMentions(content, parentEvent)
const hashtags = extractHashtags(content)
const tags = hashtags
.map((hashtag) => buildTTag(hashtag))
.concat(quoteEventIds.map((eventId) => buildQTag(eventId)))
.concat(quoteEventHexIds.map((eventId) => buildQTag(eventId)))
.concat(quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
const images = extractImagesFromContent(content)
if (images && images.length) {
@@ -357,7 +370,7 @@ export async function createPollDraftEvent(
isNsfw?: boolean
} = {}
): Promise<TDraftEvent> {
const { quoteEventIds } = await extractRelatedEventIds(question)
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(question)
const hashtags = extractHashtags(question)
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
@@ -369,7 +382,8 @@ export async function createPollDraftEvent(
}
// q tags
tags.push(...quoteEventIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteEventHexIds.map((eventId) => buildQTag(eventId)))
tags.push(...quoteReplaceableCoordinates.map((coordinate) => buildReplaceableQTag(coordinate)))
// p tags
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
@@ -441,10 +455,11 @@ function generateImetaTags(imageUrls: string[]) {
}
async function extractRelatedEventIds(content: string, parentEvent?: Event) {
const quoteEventIds: string[] = []
const quoteEventHexIds: string[] = []
const quoteReplaceableCoordinates: string[] = []
let rootETag: string[] = []
let parentETag: string[] = []
const matches = content.match(/nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g)
const matches = content.match(EMBEDDED_EVENT_REGEX)
const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item)
@@ -455,9 +470,14 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
const id = m.split(':')[1]
const { type, data } = nip19.decode(id)
if (type === 'nevent') {
addToSet(quoteEventIds, data.id)
addToSet(quoteEventHexIds, data.id)
} else if (type === 'note') {
addToSet(quoteEventIds, data)
addToSet(quoteEventHexIds, data)
} else if (type === 'naddr') {
addToSet(
quoteReplaceableCoordinates,
getReplaceableCoordinate(data.kind, data.pubkey, data.identifier)
)
}
} catch (e) {
console.error(e)
@@ -486,14 +506,16 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
}
return {
quoteEventIds,
quoteEventHexIds,
quoteReplaceableCoordinates,
rootETag,
parentETag
}
}
async function extractCommentMentions(content: string, parentEvent: Event) {
const quoteEventIds: string[] = []
const quoteEventHexIds: string[] = []
const quoteReplaceableCoordinates: string[] = []
const isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
const rootCoordinateTag = isComment
? parentEvent.tags.find(tagNameEquals('A'))
@@ -509,15 +531,20 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
if (!arr.includes(item)) arr.push(item)
}
const matches = content.match(/nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+)/g)
const matches = content.match(EMBEDDED_EVENT_REGEX)
for (const m of matches || []) {
try {
const id = m.split(':')[1]
const { type, data } = nip19.decode(id)
if (type === 'nevent') {
addToSet(quoteEventIds, data.id)
addToSet(quoteEventHexIds, data.id)
} else if (type === 'note') {
addToSet(quoteEventIds, data)
addToSet(quoteEventHexIds, data)
} else if (type === 'naddr') {
addToSet(
quoteReplaceableCoordinates,
getReplaceableCoordinate(data.kind, data.pubkey, data.identifier)
)
}
} catch (e) {
console.error(e)
@@ -525,7 +552,8 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
}
return {
quoteEventIds,
quoteEventHexIds,
quoteReplaceableCoordinates,
rootEventId,
rootCoordinateTag,
rootKind,
@@ -601,6 +629,10 @@ function buildQTag(eventHexId: string) {
return trimTagEnd(['q', eventHexId, client.getEventHint(eventHexId)]) // TODO: pubkey
}
function buildReplaceableQTag(coordinate: string) {
return trimTagEnd(['q', coordinate])
}
function buildRTag(url: string, scope: TMailboxRelayScope) {
return scope === 'both' ? ['r', url, scope] : ['r', url]
}

View File

@@ -1,4 +1,5 @@
import { BIG_RELAY_URLS } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
import client from '@/services/client.service'
@@ -10,8 +11,9 @@ export type TNoteStats = {
likeIdSet: Set<string>
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
repostPubkeySet: Set<string>
reposts: { id: string; pubkey: string; created_at: number }[]
zapPrSet: Set<string>
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
zaps: { pr: string; pubkey: string; amount: number; created_at: number; comment?: string }[]
updatedAt?: number
}
@@ -37,6 +39,11 @@ class NoteStatsService {
client.fetchRelayList(event.pubkey),
client.fetchProfile(event.pubkey)
])
const replaceableCoordinate = isReplaceableEvent(event.kind)
? getReplaceableCoordinateFromEvent(event)
: undefined
const filters: Filter[] = [
{
'#e': [event.id],
@@ -50,12 +57,35 @@ class NoteStatsService {
}
]
if (replaceableCoordinate) {
filters.push(
{
'#a': [replaceableCoordinate],
kinds: [kinds.Reaction],
limit: 500
},
{
'#a': [replaceableCoordinate],
kinds: [kinds.Repost],
limit: 100
}
)
}
if (authorProfile?.lightningAddress) {
filters.push({
'#e': [event.id],
kinds: [kinds.Zap],
limit: 500
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
kinds: [kinds.Zap],
limit: 500
})
}
}
if (pubkey) {
@@ -65,12 +95,28 @@ class NoteStatsService {
kinds: [kinds.Reaction, kinds.Repost]
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
authors: [pubkey],
kinds: [kinds.Reaction, kinds.Repost]
})
}
if (authorProfile?.lightningAddress) {
filters.push({
'#e': [event.id],
'#P': [pubkey],
kinds: [kinds.Zap]
})
if (replaceableCoordinate) {
filters.push({
'#a': [replaceableCoordinate],
'#P': [pubkey],
kinds: [kinds.Zap]
})
}
}
}
@@ -123,6 +169,7 @@ class NoteStatsService {
pr: string,
amount: number,
comment?: string,
created_at: number = dayjs().unix(),
notify: boolean = true
) {
const old = this.noteStatsMap.get(eventId) || {}
@@ -131,7 +178,7 @@ class NoteStatsService {
if (zapPrSet.has(pr)) return
zapPrSet.add(pr)
zaps.push({ pr, pubkey, amount, comment })
zaps.push({ pr, pubkey, amount, comment, created_at })
this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps })
if (notify) {
this.notifyNoteStats(eventId)
@@ -194,8 +241,12 @@ class NoteStatsService {
const old = this.noteStatsMap.get(eventId) || {}
const repostPubkeySet = old.repostPubkeySet || new Set()
const reposts = old.reposts || []
if (repostPubkeySet.has(evt.pubkey)) return
repostPubkeySet.add(evt.pubkey)
this.noteStatsMap.set(eventId, { ...old, repostPubkeySet })
reposts.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(eventId, { ...old, repostPubkeySet, reposts })
return eventId
}
@@ -205,7 +256,15 @@ class NoteStatsService {
const { originalEventId, senderPubkey, invoice, amount, comment } = info
if (!originalEventId || !senderPubkey) return
return this.addZap(senderPubkey, originalEventId, invoice, amount, comment, false)
return this.addZap(
senderPubkey,
originalEventId,
invoice,
amount,
comment,
evt.created_at,
false
)
}
}