diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 6c5d39f..b9022d0 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -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 emojiInfos = getEmojiInfosFromEmojiTags(event.tags) + const nodes = parseContent(_content, [ + EmbeddedYoutubeParser, + EmbeddedImageParser, + EmbeddedMediaParser, + EmbeddedNormalUrlParser, + EmbeddedLNInvoiceParser, + EmbeddedWebsocketUrlParser, + EmbeddedEventParser, + EmbeddedMentionParser, + EmbeddedHashtagParser, + EmbeddedEmojiParser + ]) - const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') - const lastNormalUrl = - typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined - - return ( -
- {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 ( - - ) - } - if (node.type === 'media') { - return - } - if (node.type === 'url') { - return - } - if (node.type === 'invoice') { - return - } - if (node.type === 'websocket-url') { - return + 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 === 'event') { - const id = node.data.split(':')[1] - return - } - if (node.type === 'mention') { - return - } - if (node.type === 'hashtag') { - return - } - if (node.type === 'emoji') { - const shortcode = node.data.split(':')[1] - const emoji = emojiInfos.find((e) => e.shortcode === shortcode) - if (!emoji) return node.data - return - } - if (node.type === 'youtube') { - return + 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 && } -
- ) -}) + }) + .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 ( +
+ {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 ( + + ) + } + if (node.type === 'media') { + return + } + if (node.type === 'url') { + return + } + if (node.type === 'invoice') { + return + } + if (node.type === 'websocket-url') { + return + } + if (node.type === 'event') { + const id = node.data.split(':')[1] + return + } + if (node.type === 'mention') { + return + } + if (node.type === 'hashtag') { + return + } + if (node.type === 'emoji') { + const shortcode = node.data.split(':')[1] + const emoji = emojiInfos.find((e) => e.shortcode === shortcode) + if (!emoji) return node.data + return + } + if (node.type === 'youtube') { + return + } + return null + })} + {lastNormalUrl && } +
+ ) + } +) Content.displayName = 'Content' export default Content diff --git a/src/components/Emoji/index.tsx b/src/components/Emoji/index.tsx index 66715db..c936168 100644 --- a/src/components/Emoji/index.tsx +++ b/src/components/Emoji/index.tsx @@ -5,30 +5,35 @@ import { HTMLAttributes, useState } from 'react' export default function Emoji({ emoji, - className = '' -}: HTMLAttributes & { - className?: string + classNames +}: Omit, 'className'> & { emoji: TEmoji | string + classNames?: { + text?: string + img?: string + } }) { const [hasError, setHasError] = useState(false) if (typeof emoji === 'string') { return emoji === '+' ? ( - + ) : ( - {emoji} + {emoji} ) } if (hasError) { - return {`:${emoji.shortcode}:`} + return ( + {`:${emoji.shortcode}:`} + ) } return ( {emoji.shortcode} { setHasError(false) }} diff --git a/src/components/NoteInteractions/Tabs.tsx b/src/components/NoteInteractions/Tabs.tsx index 9b13f73..1c4ba58 100644 --- a/src/components/NoteInteractions/Tabs.tsx +++ b/src/components/NoteInteractions/Tabs.tsx @@ -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 }[] diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index fdecccd..3538031 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -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('replies') + let list + switch (type) { + case 'replies': + list = + break + case 'quotes': + list = + break + case 'reactions': + list = + break + case 'reposts': + list = + break + case 'zaps': + list = + break + default: + break + } return ( <> -
- - +
+ + + + + +
+ +
- {type === 'replies' ? ( - - ) : ( - - )} + {list} ) } diff --git a/src/components/NoteStats/TopZaps.tsx b/src/components/NoteStats/TopZaps.tsx index 6378fea..f3f2438 100644 --- a/src/components/NoteStats/TopZaps.tsx +++ b/src/components/NoteStats/TopZaps.tsx @@ -22,14 +22,14 @@ export default function TopZaps({ event }: { event: Event }) { {topZaps.map((zap, index) => (
{ e.stopPropagation() setZapIndex(index) }} > - +
{formatAmount(zap.amount)}
{zap.comment}
e.stopPropagation()}> diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx index 79cc45a..37f6eec 100644 --- a/src/components/QuoteList/index.tsx +++ b/src/components/QuoteList/index.tsx @@ -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 (
-
+
{events.slice(0, showCount).map((event) => { if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) { diff --git a/src/components/ReactionList/index.tsx b/src/components/ReactionList/index.tsx new file mode 100644 index 0000000..4f8cae8 --- /dev/null +++ b/src/components/ReactionList/index.tsx @@ -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(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 ( +
+ {filteredLikes.slice(0, showCount).map((like) => ( +
push(toProfile(like.pubkey))} + > +
+ +
+ + + +
+ +
+ + +
+
+
+ ))} + +
+ +
+ {filteredLikes.length > 0 ? t('No more reactions') : t('No reactions yet')} +
+
+ ) +} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 3b8b67a..3fef286 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -274,7 +274,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event: }, []) return ( -
+
{loading && (replies.length === 0 ? : )} {!loading && until && (
{ + 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(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 ( +
+ {filteredReposts.slice(0, showCount).map((repost) => ( +
push(toProfile(repost.pubkey))} + > + + + + +
+ +
+ + +
+
+
+ ))} + +
+ +
+ {filteredReposts.length > 0 ? t('No more reposts') : t('No reposts yet')} +
+
+ ) +} diff --git a/src/components/ZapList/index.tsx b/src/components/ZapList/index.tsx new file mode 100644 index 0000000..6a5eaca --- /dev/null +++ b/src/components/ZapList/index.tsx @@ -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(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 ( +
+ {filteredZaps.slice(0, showCount).map((zap) => ( +
push(toProfile(zap.pubkey))} + > +
+ +
{formatAmount(zap.amount)}
+
+ +
+ +
+ +
+ + +
+ +
+
+
+ ))} + +
+ +
+ {filteredZaps.length > 0 ? t('No more zaps') : t('No zaps yet')} +
+
+ ) +} diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 51062b1..2f25914 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -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: 'إعادة النشر' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 9b786fa..99acc0c 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 12ae0e4..f48eb34 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9b07852..d5ac0c5 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -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' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index f5b292b..7b566b1 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -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: 'بازنشرها' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index a5f148e..3324f4d 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -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' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 94aad99..2445ea7 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -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' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index f273923..68b5828 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -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: 'リポスト' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 34123c5..4d52da4 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -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: '리포스트' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 9021545..8fd9f9f 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -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' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index 6298e1a..9ba75b7 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -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' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 1ad1c88..af28feb 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -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' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 669a723..ec926ce 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -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: 'Репосты' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 133a9fc..6e70de7 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -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: 'การรีโพสต์' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index caa2e9d..15e7514 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -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: '转发' } } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 133ada9..23c9d1b 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -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 { - 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 { - 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 { - 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 { - 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] } diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 6687bbd..27da4e4 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -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 likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[] repostPubkeySet: Set + reposts: { id: string; pubkey: string; created_at: number }[] zapPrSet: Set - 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 + ) } }