diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index a2b089a..6c3159d 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -12,14 +12,14 @@ import RepostDescription from './RepostDescription' export default function MainNoteCard({ event, className, - reposter, + reposters, embedded, originalNoteId, pinned = false }: { event: Event className?: string - reposter?: string + reposters?: string[] embedded?: boolean originalNoteId?: string pinned?: boolean @@ -37,7 +37,7 @@ export default function MainNoteCard({
{pinned && } - + 3: show "Alice, Bob, and x others reposted" (with hover card showing avatars of others) + */ export default function RepostDescription({ - reposter, + reposters, className }: { - reposter?: string | null + reposters?: string[] className?: string }) { const { t } = useTranslation() - if (!reposter) return null + if (!reposters?.length) return null return (
- + 1 && 'after:content-[","]')} + skeletonClassName="h-3" + /> + {reposters.length > 1 && ( + + )} + {reposters.length > 3 ? ( + + ) : reposters.length === 3 ? ( + + ) : null}
{t('reposted')}
) } + +function AndXOthers({ reposters }: { reposters: string[] }) { + const { t } = useTranslation() + + return ( + + + + {t('and {{x}} others', { x: reposters.length })} + + + + {reposters.map((pubkey) => ( +
+ +
+ ))} +
+
+ ) +} diff --git a/src/components/NoteCard/RepostNoteCard.tsx b/src/components/NoteCard/RepostNoteCard.tsx index 5cd562c..0dfd636 100644 --- a/src/components/NoteCard/RepostNoteCard.tsx +++ b/src/components/NoteCard/RepostNoteCard.tsx @@ -1,9 +1,9 @@ import { isMentioningMutedUsers } from '@/lib/event' -import { tagNameEquals } from '@/lib/tag' +import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/MuteListProvider' import client from '@/services/client.service' -import { Event, kinds, nip19, verifyEvent } from 'nostr-tools' +import { Event, kinds, verifyEvent } from 'nostr-tools' import { useEffect, useMemo, useState } from 'react' import MainNoteCard from './MainNoteCard' @@ -51,15 +51,20 @@ export default function RepostNoteCard({ return } - const [, id, relay, , pubkey] = event.tags.find(tagNameEquals('e')) ?? [] - if (!id) { + let targetEventId: string | undefined + const aTag = event.tags.find(tagNameEquals('a')) + if (aTag) { + targetEventId = generateBech32IdFromATag(aTag) + } else { + const eTag = event.tags.find(tagNameEquals('e')) + if (eTag) { + targetEventId = generateBech32IdFromETag(eTag) + } + } + if (!targetEventId) { return } - const targetEventId = nip19.neventEncode({ - id, - relays: relay ? [relay] : [], - author: pubkey - }) + const targetEvent = await client.fetchEvent(targetEventId) if (targetEvent) { setTargetEvent(targetEvent) @@ -76,7 +81,7 @@ export default function RepostNoteCard({ return ( diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 5dd8c32..b095315 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -11,12 +11,14 @@ export default function NoteCard({ event, className, filterMutedNotes = true, - pinned = false + pinned = false, + reposters }: { event: Event className?: string filterMutedNotes?: boolean pinned?: boolean + reposters?: string[] }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -41,7 +43,7 @@ export default function NoteCard({ /> ) } - return + return } export function NoteCardLoadingSkeleton() { diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 4378f41..4fb2ba7 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,11 +1,12 @@ import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' import { - getReplaceableCoordinateFromEvent, + getEventKey, + getEventKeyFromTag, isMentioningMutedUsers, - isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' +import { tagNameEquals } from '@/lib/tag' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' @@ -15,7 +16,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' -import { Event } from 'nostr-tools' +import { Event, kinds, verifyEvent } from 'nostr-tools' import { decode } from 'nostr-tools/nip19' import { forwardRef, @@ -113,34 +114,95 @@ const NoteList = forwardRef( [hideReplies, hideUntrustedNotes, mutePubkeySet, pinnedEventIds, isEventDeleted, filterFn] ) - const filteredEvents = useMemo(() => { - const idSet = new Set() + const filteredNotes = useMemo(() => { + // Store processed event keys to avoid duplicates + const keySet = new Set() + // Map to track reposters for each event key + const repostersMap = new Map>() + // Final list of filtered events + const filteredEvents: Event[] = [] - return events.slice(0, showCount).filter((evt) => { - if (shouldHideEvent(evt)) return false + events.slice(0, showCount).forEach((evt) => { + const key = getEventKey(evt) + if (keySet.has(key)) return + keySet.add(key) - const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id - if (idSet.has(id)) { - return false + if (shouldHideEvent(evt)) return + if (evt.kind !== kinds.Repost) { + filteredEvents.push(evt) + return } - idSet.add(id) - return true + + const eventFromContent = evt.content ? (JSON.parse(evt.content) as Event) : null + if (eventFromContent && verifyEvent(eventFromContent)) { + if (eventFromContent.kind === kinds.Repost) { + return + } + if (shouldHideEvent(eventFromContent)) return + + client.addEventToCache(eventFromContent) + const targetSeenOn = client.getSeenEventRelays(eventFromContent.id) + if (targetSeenOn.length === 0) { + const seenOn = client.getSeenEventRelays(evt.id) + seenOn.forEach((relay) => { + client.trackEventSeenOn(eventFromContent.id, relay) + }) + } + + const targetEventKey = getEventKey(eventFromContent) + const reposters = repostersMap.get(targetEventKey) + if (reposters) { + reposters.add(evt.pubkey) + } else { + repostersMap.set(targetEventKey, new Set([evt.pubkey])) + } + // If the target event is not already included, add it now + if (!keySet.has(targetEventKey)) { + filteredEvents.push(eventFromContent) + keySet.add(targetEventKey) + } + return + } + + const targetTag = evt.tags.find(tagNameEquals('a')) ?? evt.tags.find(tagNameEquals('e')) + if (targetTag) { + const targetEventKey = getEventKeyFromTag(targetTag) + if (targetEventKey) { + // Add to reposters map + const reposters = repostersMap.get(targetEventKey) + if (reposters) { + reposters.add(evt.pubkey) + } else { + repostersMap.set(targetEventKey, new Set([evt.pubkey])) + } + // If the target event is already included, skip adding this repost + if (keySet.has(targetEventKey)) { + return + } + } + } + // If we can't find the original event, just show the repost itself + filteredEvents.push(evt) + return + }) + + return filteredEvents.map((evt) => { + const key = getEventKey(evt) + return { key, event: evt, reposters: Array.from(repostersMap.get(key) ?? []) } }) }, [events, showCount, shouldHideEvent]) const filteredNewEvents = useMemo(() => { - const idSet = new Set() + const keySet = new Set() return newEvents.filter((event: Event) => { if (shouldHideEvent(event)) return false - const id = isReplaceableEvent(event.kind) - ? getReplaceableCoordinateFromEvent(event) - : event.id - if (idSet.has(id)) { + const key = getEventKey(event) + if (keySet.has(key)) { return false } - idSet.add(id) + keySet.add(key) return true }) }, [newEvents, shouldHideEvent]) @@ -306,12 +368,13 @@ const NoteList = forwardRef( {pinnedEventIds.map((id) => ( ))} - {filteredEvents.map((event) => ( + {filteredNotes.map(({ key, event, reposters }) => ( ))} {hasMore || loading ? ( diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index a185ab9..adbf8e7 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -489,6 +489,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.', 'Explore Relays': 'استكشف المرحلات', - 'Choose a feed': 'اختر خلاصة' + 'Choose a feed': 'اختر خلاصة', + 'and {{x}} others': 'و {{x}} آخرون' } } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 2a879f5..391e676 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -503,6 +503,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble ist ein Client, der sich auf das Durchsuchen von Relays konzentriert. Beginnen Sie mit der Erkundung interessanter Relays oder melden Sie sich an, um Ihren Following-Feed anzuzeigen.', 'Explore Relays': 'Relays erkunden', - 'Choose a feed': 'Wähle einen Feed' + 'Choose a feed': 'Wähle einen Feed', + 'and {{x}} others': 'und {{x}} andere' } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9afbfa1..2745f98 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -488,6 +488,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.', 'Explore Relays': 'Explore Relays', - 'Choose a feed': 'Choose a feed' + 'Choose a feed': 'Choose a feed', + 'and {{x}} others': 'and {{x}} others' } } diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 57ae48b..71d6560 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -497,6 +497,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble es un cliente enfocado en explorar relays. Comienza explorando relays interesantes o inicia sesión para ver tu feed de seguidos.', 'Explore Relays': 'Explorar Relays', - 'Choose a feed': 'Elige un feed' + 'Choose a feed': 'Elige un feed', + 'and {{x}} others': 'y {{x}} otros' } } diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts index 68139be..8202d70 100644 --- a/src/i18n/locales/fa.ts +++ b/src/i18n/locales/fa.ts @@ -492,6 +492,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble یک کلاینت متمرکز بر مرور رله‌هاست. با کاوش در رله‌های جالب شروع کنید یا وارد شوید تا فید دنبال‌کننده‌های خود را مشاهده کنید.', 'Explore Relays': 'کاوش در رله‌ها', - 'Choose a feed': 'یک فید انتخاب کنید' + 'Choose a feed': 'یک فید انتخاب کنید', + 'and {{x}} others': 'و {{x}} دیگر' } } diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index a681fb6..c3a662e 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -502,6 +502,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': "Jumble est un client axé sur la navigation des relais. Commencez par explorer des relais intéressants ou connectez-vous pour voir votre fil d'abonnements.", 'Explore Relays': 'Explorer les relais', - 'Choose a feed': 'Choisir un fil' + 'Choose a feed': 'Choisir un fil', + 'and {{x}} others': 'et {{x}} autres' } } diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index 242d8f5..1bfe96c 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -494,6 +494,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।', 'Explore Relays': 'रिले एक्सप्लोर करें', - 'Choose a feed': 'एक फीड चुनें' + 'Choose a feed': 'एक फीड चुनें', + 'and {{x}} others': 'और {{x}} अन्य' } } diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index 0247b84..aeb596c 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -497,6 +497,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble è un client focalizzato sulla navigazione dei relay. Inizia esplorando relay interessanti o effettua il login per visualizzare il tuo feed di following.', 'Explore Relays': 'Esplora Relay', - 'Choose a feed': 'Scegli un feed' + 'Choose a feed': 'Scegli un feed', + 'and {{x}} others': 'e altri {{x}}' } } diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 453e9ca..a717e15 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -493,6 +493,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumbleはリレーを閲覧することに焦点を当てたクライアントです。興味深いリレーを探索するか、ログインしてフォロー中のフィードを表示してください。', 'Explore Relays': 'リレーを探索', - 'Choose a feed': 'フィードを選択' + 'Choose a feed': 'フィードを選択', + 'and {{x}} others': 'および他{{x}}人' } } diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 62d4822..fa23798 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -493,6 +493,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble은 릴레이 탐색에 중점을 둔 클라이언트입니다. 흥미로운 릴레이를 탐색하거나 로그인하여 팔로잉 피드를 확인하세요.', 'Explore Relays': '릴레이 탐색', - 'Choose a feed': '피드 선택' + 'Choose a feed': '피드 선택', + 'and {{x}} others': '및 기타 {{x}}명' } } diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index fe29e49..652c55f 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -497,6 +497,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble to klient skupiony na przeglądaniu relay. Zacznij od eksploracji ciekawych relay lub zaloguj się, aby zobaczyć swój feed obserwowanych.', 'Explore Relays': 'Eksploruj Relay', - 'Choose a feed': 'Wybierz feed' + 'Choose a feed': 'Wybierz feed', + 'and {{x}} others': 'i {{x}} innych' } } diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts index b3533a3..10cfeee 100644 --- a/src/i18n/locales/pt-BR.ts +++ b/src/i18n/locales/pt-BR.ts @@ -494,6 +494,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble é um cliente focado em navegar relays. Comece explorando relays interessantes ou faça login para ver seu feed de seguidos.', 'Explore Relays': 'Explorar Relays', - 'Choose a feed': 'Escolha um feed' + 'Choose a feed': 'Escolha um feed', + 'and {{x}} others': 'e {{x}} outros' } } diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts index 016fb8b..b6ce7a3 100644 --- a/src/i18n/locales/pt-PT.ts +++ b/src/i18n/locales/pt-PT.ts @@ -497,6 +497,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble é um cliente focado em explorar relays. Comece por explorar relays interessantes ou inicie sessão para ver o seu feed de seguidos.', 'Explore Relays': 'Explorar Relays', - 'Choose a feed': 'Escolha um feed' + 'Choose a feed': 'Escolha um feed', + 'and {{x}} others': 'e {{x}} outros' } } diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 3893505..ea719b2 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -499,6 +499,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble — это клиент, ориентированный на просмотр relay. Начните с изучения интересных relay или войдите, чтобы увидеть ленту подписок.', 'Explore Relays': 'Исследовать Relay', - 'Choose a feed': 'Выберите ленту' + 'Choose a feed': 'Выберите ленту', + 'and {{x}} others': 'и {{x}} других' } } diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index a314eba..fa5a958 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -487,6 +487,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble เป็นไคลเอนต์ที่เน้นการเรียกดูรีเลย์ เริ่มต้นด้วยการสำรวจรีเลย์ที่น่าสนใจ หรือเข้าสู่ระบบเพื่อดูฟีดที่คุณติดตาม', 'Explore Relays': 'สำรวจรีเลย์', - 'Choose a feed': 'เลือกฟีด' + 'Choose a feed': 'เลือกฟีด', + 'and {{x}} others': 'และอื่น ๆ {{x}} รายการ' } } diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index acaa789..83b6f99 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -485,6 +485,7 @@ export default { 'Jumble is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.': 'Jumble 是一个专注于浏览服务器的客户端。从探索有趣的服务器开始,或者登录查看你的关注动态。', 'Explore Relays': '探索服务器', - 'Choose a feed': '选择一个动态' + 'Choose a feed': '选择一个动态', + 'and {{x}} others': '和其他 {{x}} 人' } }