feat: add more note interactions lists (#467)
Co-authored-by: Trevor Arjeski <tmarjeski@gmail.com>
This commit is contained in:
@@ -33,9 +33,13 @@ import MediaPlayer from '../MediaPlayer'
|
|||||||
import WebPreview from '../WebPreview'
|
import WebPreview from '../WebPreview'
|
||||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||||
|
|
||||||
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
const Content = memo(
|
||||||
const translatedEvent = useTranslatedEvent(event.id)
|
({ event, content, className }: { event?: Event; content?: string; className?: string }) => {
|
||||||
const nodes = parseContent(translatedEvent?.content ?? event.content, [
|
const translatedEvent = useTranslatedEvent(event?.id)
|
||||||
|
const _content = translatedEvent?.content ?? event?.content ?? content
|
||||||
|
if (!_content) return null
|
||||||
|
|
||||||
|
const nodes = parseContent(_content, [
|
||||||
EmbeddedYoutubeParser,
|
EmbeddedYoutubeParser,
|
||||||
EmbeddedImageParser,
|
EmbeddedImageParser,
|
||||||
EmbeddedMediaParser,
|
EmbeddedMediaParser,
|
||||||
@@ -48,7 +52,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
EmbeddedEmojiParser
|
EmbeddedEmojiParser
|
||||||
])
|
])
|
||||||
|
|
||||||
const imageInfos = getImageInfosFromEvent(event)
|
const imageInfos = event ? getImageInfosFromEvent(event) : []
|
||||||
const allImages = nodes
|
const allImages = nodes
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
if (node.type === 'image') {
|
if (node.type === 'image') {
|
||||||
@@ -58,14 +62,14 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
}
|
}
|
||||||
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
||||||
return tag
|
return tag
|
||||||
? getImageInfoFromImetaTag(tag, event.pubkey)
|
? getImageInfoFromImetaTag(tag, event?.pubkey)
|
||||||
: { url: node.data, pubkey: event.pubkey }
|
: { url: node.data, pubkey: event?.pubkey }
|
||||||
}
|
}
|
||||||
if (node.type === 'images') {
|
if (node.type === 'images') {
|
||||||
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
||||||
return urls.map((url) => {
|
return urls.map((url) => {
|
||||||
const imageInfo = imageInfos.find((image) => image.url === url)
|
const imageInfo = imageInfos.find((image) => image.url === url)
|
||||||
return imageInfo ?? { url, pubkey: event.pubkey }
|
return imageInfo ?? { url, pubkey: event?.pubkey }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -74,7 +78,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
.flat() as TImageInfo[]
|
.flat() as TImageInfo[]
|
||||||
let imageIndex = 0
|
let imageIndex = 0
|
||||||
|
|
||||||
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
|
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
|
||||||
|
|
||||||
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
|
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
|
||||||
const lastNormalUrl =
|
const lastNormalUrl =
|
||||||
@@ -91,7 +95,13 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||||
imageIndex = end
|
imageIndex = end
|
||||||
return (
|
return (
|
||||||
<ImageGallery className="mt-2" key={index} images={allImages} start={start} end={end} />
|
<ImageGallery
|
||||||
|
className="mt-2"
|
||||||
|
key={index}
|
||||||
|
images={allImages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (node.type === 'media') {
|
if (node.type === 'media') {
|
||||||
@@ -120,7 +130,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
const shortcode = node.data.split(':')[1]
|
const shortcode = node.data.split(':')[1]
|
||||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||||
if (!emoji) return node.data
|
if (!emoji) return node.data
|
||||||
return <Emoji emoji={emoji} key={index} className="size-4" />
|
return <Emoji emoji={emoji} key={index} />
|
||||||
}
|
}
|
||||||
if (node.type === 'youtube') {
|
if (node.type === 'youtube') {
|
||||||
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
|
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
|
||||||
@@ -130,6 +140,7 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
|
|||||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
Content.displayName = 'Content'
|
Content.displayName = 'Content'
|
||||||
export default Content
|
export default Content
|
||||||
|
|||||||
@@ -5,30 +5,35 @@ import { HTMLAttributes, useState } from 'react'
|
|||||||
|
|
||||||
export default function Emoji({
|
export default function Emoji({
|
||||||
emoji,
|
emoji,
|
||||||
className = ''
|
classNames
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & {
|
||||||
className?: string
|
|
||||||
emoji: TEmoji | string
|
emoji: TEmoji | string
|
||||||
|
classNames?: {
|
||||||
|
text?: string
|
||||||
|
img?: string
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
const [hasError, setHasError] = useState(false)
|
const [hasError, setHasError] = useState(false)
|
||||||
|
|
||||||
if (typeof emoji === 'string') {
|
if (typeof emoji === 'string') {
|
||||||
return emoji === '+' ? (
|
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) {
|
if (hasError) {
|
||||||
return <span className={cn('whitespace-nowrap', className)}>{`:${emoji.shortcode}:`}</span>
|
return (
|
||||||
|
<span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={emoji.url}
|
src={emoji.url}
|
||||||
alt={emoji.shortcode}
|
alt={emoji.shortcode}
|
||||||
className={cn('inline-block size-4', className)}
|
className={cn('inline-block size-4', classNames?.img)}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setHasError(false)
|
setHasError(false)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useRef, useEffect, useState } from 'react'
|
import { useRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
export type TTabValue = 'replies' | 'quotes'
|
export type TTabValue = 'replies' | 'quotes' | 'reactions' | 'reposts' | 'zaps'
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ value: 'replies', label: 'Replies' },
|
{ value: 'replies', label: 'Replies' },
|
||||||
|
{ value: 'zaps', label: 'Zaps' },
|
||||||
|
{ value: 'reposts', label: 'Reposts' },
|
||||||
|
{ value: 'reactions', label: 'Reactions' },
|
||||||
{ value: 'quotes', label: 'Quotes' }
|
{ value: 'quotes', label: 'Quotes' }
|
||||||
] as { value: TTabValue; label: string }[]
|
] as { value: TTabValue; label: string }[]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import HideUntrustedContentButton from '../HideUntrustedContentButton'
|
import HideUntrustedContentButton from '../HideUntrustedContentButton'
|
||||||
import QuoteList from '../QuoteList'
|
import QuoteList from '../QuoteList'
|
||||||
|
import ReactionList from '../ReactionList'
|
||||||
import ReplyNoteList from '../ReplyNoteList'
|
import ReplyNoteList from '../ReplyNoteList'
|
||||||
|
import RepostList from '../RepostList'
|
||||||
|
import ZapList from '../ZapList'
|
||||||
import { Tabs, TTabValue } from './Tabs'
|
import { Tabs, TTabValue } from './Tabs'
|
||||||
|
|
||||||
export default function NoteInteractions({
|
export default function NoteInteractions({
|
||||||
@@ -14,19 +18,41 @@ export default function NoteInteractions({
|
|||||||
event: Event
|
event: Event
|
||||||
}) {
|
}) {
|
||||||
const [type, setType] = useState<TTabValue>('replies')
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between pr-1">
|
<div className="flex items-center justify-between">
|
||||||
|
<ScrollArea className="flex-1 w-0">
|
||||||
<Tabs selectedTab={type} onTabChange={setType} />
|
<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" />
|
<HideUntrustedContentButton type="interactions" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
{type === 'replies' ? (
|
{list}
|
||||||
<ReplyNoteList index={pageIndex} event={event} />
|
|
||||||
) : (
|
|
||||||
<QuoteList event={event} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export default function TopZaps({ event }: { event: Event }) {
|
|||||||
{topZaps.map((zap, index) => (
|
{topZaps.map((zap, index) => (
|
||||||
<div
|
<div
|
||||||
key={zap.pr}
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setZapIndex(index)
|
setZapIndex(index)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
|
<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="font-semibold">{formatAmount(zap.amount)}</div>
|
||||||
<div className="truncate">{zap.comment}</div>
|
<div className="truncate">{zap.comment}</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
|
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
@@ -38,7 +39,9 @@ export default function QuoteList({ event, className }: { event: Event; classNam
|
|||||||
{
|
{
|
||||||
urls: relayUrls,
|
urls: relayUrls,
|
||||||
filter: {
|
filter: {
|
||||||
'#q': [event.id],
|
'#q': [
|
||||||
|
isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
|
||||||
|
],
|
||||||
kinds: [
|
kinds: [
|
||||||
kinds.ShortTextNote,
|
kinds.ShortTextNote,
|
||||||
kinds.Highlights,
|
kinds.Highlights,
|
||||||
@@ -130,7 +133,7 @@ export default function QuoteList({ event, className }: { event: Event; classNam
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-[80vh]">
|
||||||
<div>
|
<div>
|
||||||
{events.slice(0, showCount).map((event) => {
|
{events.slice(0, showCount).map((event) => {
|
||||||
if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {
|
if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {
|
||||||
|
|||||||
89
src/components/ReactionList/index.tsx
Normal file
89
src/components/ReactionList/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -274,7 +274,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-[80vh]">
|
||||||
{loading && (replies.length === 0 ? <ReplyNoteSkeleton /> : <LoadingBar />)}
|
{loading && (replies.length === 0 ? <ReplyNoteSkeleton /> : <LoadingBar />)}
|
||||||
{!loading && until && (
|
{!loading && until && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
81
src/components/RepostList/index.tsx
Normal file
81
src/components/RepostList/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
src/components/ZapList/index.tsx
Normal file
84
src/components/ZapList/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -326,6 +326,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'تم البث بنجاح إلى المرحل: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'تم البث بنجاح إلى المرحل: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'فشل البث إلى المرحل: {{url}}. خطأ: {{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: 'إعادة النشر'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,6 +333,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Erfolgreich an Relay gesendet: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Erfolgreich an Relay gesendet: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Fehler beim Senden an Relay: {{url}}. Fehler: {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Successfully broadcasted to relay: {{url}}',
|
'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}}':
|
||||||
'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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Transmitido exitosamente al relé: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Transmitido exitosamente al relé: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Error al transmitir al relé: {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'با موفقیت به رله پخش شد: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'با موفقیت به رله پخش شد: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'پخش به رله ناموفق بود: {{url}}. خطا: {{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: 'بازنشرها'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Diffusion réussie vers le relais : {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Diffusion réussie vers le relais : {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Échec de la diffusion vers le relais : {{url}}. Erreur : {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Trasmesso con successo al relay: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Trasmesso con successo al relay: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Errore nella trasmissione al relay: {{url}}. Errore: {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,6 +329,13 @@ export default {
|
|||||||
'リレイへのブロードキャストが成功しました:{{url}}',
|
'リレイへのブロードキャストが成功しました:{{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'リレイへのブロードキャストが失敗しました:{{url}}。エラー:{{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: 'リポスト'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,6 +328,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': '릴레이로 브로드캐스트에 성공했습니다: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': '릴레이로 브로드캐스트에 성공했습니다: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'릴레이로 브로드캐스트에 실패했습니다: {{url}}. 오류: {{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: '리포스트'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Pomyślnie transmitowano do przekaźnika: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Pomyślnie transmitowano do przekaźnika: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Nie udało się transmitować do przekaźnika: {{url}}. Błąd: {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,6 +330,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Falha ao transmitir para o relay: {{url}}. Erro: {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Transmitido com sucesso para o relay: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Falha ao transmitir para o relay: {{url}}. Erro: {{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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'Успешно транслировано в релей: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'Успешно транслировано в релей: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'Ошибка трансляции в релей: {{url}}. Ошибка: {{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: 'Репосты'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': 'ส่งสัญญาณไปยังรีเลย์สำเร็จแล้ว: {{url}}',
|
'Successfully broadcasted to relay: {{url}}': 'ส่งสัญญาณไปยังรีเลย์สำเร็จแล้ว: {{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'การส่งสัญญาณไปยังรีเลย์ล้มเหลว: {{url}} ข้อผิดพลาด: {{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: 'การรีโพสต์'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,6 +324,13 @@ export default {
|
|||||||
'Successfully broadcasted to relay: {{url}}': '成功广播到服务器:{{url}}',
|
'Successfully broadcasted to relay: {{url}}': '成功广播到服务器:{{url}}',
|
||||||
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
'Failed to broadcast to relay: {{url}}. Error: {{error}}':
|
||||||
'广播到服务器失败:{{url}}。错误:{{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: '转发'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 client from '@/services/client.service'
|
||||||
import mediaUpload from '@/services/media-upload.service'
|
import mediaUpload from '@/services/media-upload.service'
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds, nip19 } from 'nostr-tools'
|
import { Event, kinds, nip19 } from 'nostr-tools'
|
||||||
import {
|
import {
|
||||||
|
getReplaceableCoordinate,
|
||||||
getReplaceableCoordinateFromEvent,
|
getReplaceableCoordinateFromEvent,
|
||||||
getRootETag,
|
getRootETag,
|
||||||
isProtectedEvent,
|
isProtectedEvent,
|
||||||
@@ -54,6 +55,10 @@ 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 (isReplaceableEvent(event.kind)) {
|
||||||
|
tags.push(buildATag(event))
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: kinds.Repost,
|
kind: kinds.Repost,
|
||||||
content: isProtected ? '' : JSON.stringify(event),
|
content: isProtected ? '' : JSON.stringify(event),
|
||||||
@@ -73,10 +78,8 @@ export async function createShortTextNoteDraftEvent(
|
|||||||
isNsfw?: boolean
|
isNsfw?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TDraftEvent> {
|
): Promise<TDraftEvent> {
|
||||||
const { quoteEventIds, rootETag, parentETag } = await extractRelatedEventIds(
|
const { quoteEventHexIds, quoteReplaceableCoordinates, rootETag, parentETag } =
|
||||||
content,
|
await extractRelatedEventIds(content, options.parentEvent)
|
||||||
options.parentEvent
|
|
||||||
)
|
|
||||||
const hashtags = extractHashtags(content)
|
const hashtags = extractHashtags(content)
|
||||||
|
|
||||||
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
|
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
|
||||||
@@ -88,7 +91,8 @@ export async function createShortTextNoteDraftEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// q tags
|
// 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
|
// e tags
|
||||||
if (rootETag.length) {
|
if (rootETag.length) {
|
||||||
@@ -153,7 +157,7 @@ export async function createPictureNoteDraftEvent(
|
|||||||
protectedEvent?: boolean
|
protectedEvent?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TDraftEvent> {
|
): Promise<TDraftEvent> {
|
||||||
const { quoteEventIds } = await extractRelatedEventIds(content)
|
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(content)
|
||||||
const hashtags = extractHashtags(content)
|
const hashtags = extractHashtags(content)
|
||||||
if (!pictureInfos.length) {
|
if (!pictureInfos.length) {
|
||||||
throw new Error('No images found in content')
|
throw new Error('No images found in content')
|
||||||
@@ -162,7 +166,8 @@ export async function createPictureNoteDraftEvent(
|
|||||||
const tags = pictureInfos
|
const tags = pictureInfos
|
||||||
.map((info) => buildImetaTag(info.tags))
|
.map((info) => buildImetaTag(info.tags))
|
||||||
.concat(hashtags.map((hashtag) => buildTTag(hashtag)))
|
.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)))
|
.concat(mentions.map((pubkey) => buildPTag(pubkey)))
|
||||||
|
|
||||||
if (options.addClientTag) {
|
if (options.addClientTag) {
|
||||||
@@ -192,13 +197,21 @@ export async function createCommentDraftEvent(
|
|||||||
isNsfw?: boolean
|
isNsfw?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TDraftEvent> {
|
): Promise<TDraftEvent> {
|
||||||
const { quoteEventIds, rootEventId, rootCoordinateTag, rootKind, rootPubkey, rootUrl } =
|
const {
|
||||||
await extractCommentMentions(content, parentEvent)
|
quoteEventHexIds,
|
||||||
|
quoteReplaceableCoordinates,
|
||||||
|
rootEventId,
|
||||||
|
rootCoordinateTag,
|
||||||
|
rootKind,
|
||||||
|
rootPubkey,
|
||||||
|
rootUrl
|
||||||
|
} = await extractCommentMentions(content, parentEvent)
|
||||||
const hashtags = extractHashtags(content)
|
const hashtags = extractHashtags(content)
|
||||||
|
|
||||||
const tags = hashtags
|
const tags = hashtags
|
||||||
.map((hashtag) => buildTTag(hashtag))
|
.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)
|
const images = extractImagesFromContent(content)
|
||||||
if (images && images.length) {
|
if (images && images.length) {
|
||||||
@@ -357,7 +370,7 @@ export async function createPollDraftEvent(
|
|||||||
isNsfw?: boolean
|
isNsfw?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<TDraftEvent> {
|
): Promise<TDraftEvent> {
|
||||||
const { quoteEventIds } = await extractRelatedEventIds(question)
|
const { quoteEventHexIds, quoteReplaceableCoordinates } = await extractRelatedEventIds(question)
|
||||||
const hashtags = extractHashtags(question)
|
const hashtags = extractHashtags(question)
|
||||||
|
|
||||||
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
|
const tags = hashtags.map((hashtag) => buildTTag(hashtag))
|
||||||
@@ -369,7 +382,8 @@ export async function createPollDraftEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// q tags
|
// 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
|
// p tags
|
||||||
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
|
tags.push(...mentions.map((pubkey) => buildPTag(pubkey)))
|
||||||
@@ -441,10 +455,11 @@ function generateImetaTags(imageUrls: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
||||||
const quoteEventIds: string[] = []
|
const quoteEventHexIds: string[] = []
|
||||||
|
const quoteReplaceableCoordinates: string[] = []
|
||||||
let rootETag: string[] = []
|
let rootETag: string[] = []
|
||||||
let parentETag: 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) => {
|
const addToSet = (arr: string[], item: string) => {
|
||||||
if (!arr.includes(item)) arr.push(item)
|
if (!arr.includes(item)) arr.push(item)
|
||||||
@@ -455,9 +470,14 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
|||||||
const id = m.split(':')[1]
|
const id = m.split(':')[1]
|
||||||
const { type, data } = nip19.decode(id)
|
const { type, data } = nip19.decode(id)
|
||||||
if (type === 'nevent') {
|
if (type === 'nevent') {
|
||||||
addToSet(quoteEventIds, data.id)
|
addToSet(quoteEventHexIds, data.id)
|
||||||
} else if (type === 'note') {
|
} 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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -486,14 +506,16 @@ async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quoteEventIds,
|
quoteEventHexIds,
|
||||||
|
quoteReplaceableCoordinates,
|
||||||
rootETag,
|
rootETag,
|
||||||
parentETag
|
parentETag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractCommentMentions(content: string, parentEvent: Event) {
|
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 isComment = [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT].includes(parentEvent.kind)
|
||||||
const rootCoordinateTag = isComment
|
const rootCoordinateTag = isComment
|
||||||
? parentEvent.tags.find(tagNameEquals('A'))
|
? parentEvent.tags.find(tagNameEquals('A'))
|
||||||
@@ -509,15 +531,20 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
|
|||||||
if (!arr.includes(item)) arr.push(item)
|
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 || []) {
|
for (const m of matches || []) {
|
||||||
try {
|
try {
|
||||||
const id = m.split(':')[1]
|
const id = m.split(':')[1]
|
||||||
const { type, data } = nip19.decode(id)
|
const { type, data } = nip19.decode(id)
|
||||||
if (type === 'nevent') {
|
if (type === 'nevent') {
|
||||||
addToSet(quoteEventIds, data.id)
|
addToSet(quoteEventHexIds, data.id)
|
||||||
} else if (type === 'note') {
|
} 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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -525,7 +552,8 @@ async function extractCommentMentions(content: string, parentEvent: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quoteEventIds,
|
quoteEventHexIds,
|
||||||
|
quoteReplaceableCoordinates,
|
||||||
rootEventId,
|
rootEventId,
|
||||||
rootCoordinateTag,
|
rootCoordinateTag,
|
||||||
rootKind,
|
rootKind,
|
||||||
@@ -601,6 +629,10 @@ function buildQTag(eventHexId: string) {
|
|||||||
return trimTagEnd(['q', eventHexId, client.getEventHint(eventHexId)]) // TODO: pubkey
|
return trimTagEnd(['q', eventHexId, client.getEventHint(eventHexId)]) // TODO: pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReplaceableQTag(coordinate: string) {
|
||||||
|
return trimTagEnd(['q', coordinate])
|
||||||
|
}
|
||||||
|
|
||||||
function buildRTag(url: string, scope: TMailboxRelayScope) {
|
function buildRTag(url: string, scope: TMailboxRelayScope) {
|
||||||
return scope === 'both' ? ['r', url, scope] : ['r', url]
|
return scope === 'both' ? ['r', url, scope] : ['r', url]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BIG_RELAY_URLS } from '@/constants'
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
|
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||||
import { getZapInfoFromEvent } from '@/lib/event-metadata'
|
import { getZapInfoFromEvent } from '@/lib/event-metadata'
|
||||||
import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
|
import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
@@ -10,8 +11,9 @@ export type TNoteStats = {
|
|||||||
likeIdSet: Set<string>
|
likeIdSet: Set<string>
|
||||||
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
|
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
|
||||||
repostPubkeySet: Set<string>
|
repostPubkeySet: Set<string>
|
||||||
|
reposts: { id: string; pubkey: string; created_at: number }[]
|
||||||
zapPrSet: Set<string>
|
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
|
updatedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,11 @@ class NoteStatsService {
|
|||||||
client.fetchRelayList(event.pubkey),
|
client.fetchRelayList(event.pubkey),
|
||||||
client.fetchProfile(event.pubkey)
|
client.fetchProfile(event.pubkey)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const replaceableCoordinate = isReplaceableEvent(event.kind)
|
||||||
|
? getReplaceableCoordinateFromEvent(event)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const filters: Filter[] = [
|
const filters: Filter[] = [
|
||||||
{
|
{
|
||||||
'#e': [event.id],
|
'#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) {
|
if (authorProfile?.lightningAddress) {
|
||||||
filters.push({
|
filters.push({
|
||||||
'#e': [event.id],
|
'#e': [event.id],
|
||||||
kinds: [kinds.Zap],
|
kinds: [kinds.Zap],
|
||||||
limit: 500
|
limit: 500
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (replaceableCoordinate) {
|
||||||
|
filters.push({
|
||||||
|
'#a': [replaceableCoordinate],
|
||||||
|
kinds: [kinds.Zap],
|
||||||
|
limit: 500
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pubkey) {
|
if (pubkey) {
|
||||||
@@ -65,12 +95,28 @@ class NoteStatsService {
|
|||||||
kinds: [kinds.Reaction, kinds.Repost]
|
kinds: [kinds.Reaction, kinds.Repost]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (replaceableCoordinate) {
|
||||||
|
filters.push({
|
||||||
|
'#a': [replaceableCoordinate],
|
||||||
|
authors: [pubkey],
|
||||||
|
kinds: [kinds.Reaction, kinds.Repost]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (authorProfile?.lightningAddress) {
|
if (authorProfile?.lightningAddress) {
|
||||||
filters.push({
|
filters.push({
|
||||||
'#e': [event.id],
|
'#e': [event.id],
|
||||||
'#P': [pubkey],
|
'#P': [pubkey],
|
||||||
kinds: [kinds.Zap]
|
kinds: [kinds.Zap]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (replaceableCoordinate) {
|
||||||
|
filters.push({
|
||||||
|
'#a': [replaceableCoordinate],
|
||||||
|
'#P': [pubkey],
|
||||||
|
kinds: [kinds.Zap]
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +169,7 @@ class NoteStatsService {
|
|||||||
pr: string,
|
pr: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
comment?: string,
|
comment?: string,
|
||||||
|
created_at: number = dayjs().unix(),
|
||||||
notify: boolean = true
|
notify: boolean = true
|
||||||
) {
|
) {
|
||||||
const old = this.noteStatsMap.get(eventId) || {}
|
const old = this.noteStatsMap.get(eventId) || {}
|
||||||
@@ -131,7 +178,7 @@ class NoteStatsService {
|
|||||||
if (zapPrSet.has(pr)) return
|
if (zapPrSet.has(pr)) return
|
||||||
|
|
||||||
zapPrSet.add(pr)
|
zapPrSet.add(pr)
|
||||||
zaps.push({ pr, pubkey, amount, comment })
|
zaps.push({ pr, pubkey, amount, comment, created_at })
|
||||||
this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps })
|
this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps })
|
||||||
if (notify) {
|
if (notify) {
|
||||||
this.notifyNoteStats(eventId)
|
this.notifyNoteStats(eventId)
|
||||||
@@ -194,8 +241,12 @@ class NoteStatsService {
|
|||||||
|
|
||||||
const old = this.noteStatsMap.get(eventId) || {}
|
const old = this.noteStatsMap.get(eventId) || {}
|
||||||
const repostPubkeySet = old.repostPubkeySet || new Set()
|
const repostPubkeySet = old.repostPubkeySet || new Set()
|
||||||
|
const reposts = old.reposts || []
|
||||||
|
if (repostPubkeySet.has(evt.pubkey)) return
|
||||||
|
|
||||||
repostPubkeySet.add(evt.pubkey)
|
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
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +256,15 @@ class NoteStatsService {
|
|||||||
const { originalEventId, senderPubkey, invoice, amount, comment } = info
|
const { originalEventId, senderPubkey, invoice, amount, comment } = info
|
||||||
if (!originalEventId || !senderPubkey) return
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user