feat: aggregate multiple reposts to avoid duplicate posts in feed

This commit is contained in:
codytseng
2025-11-01 19:36:01 +08:00
parent 934c56a20d
commit 222527ec7c
21 changed files with 193 additions and 55 deletions

View File

@@ -12,14 +12,14 @@ import RepostDescription from './RepostDescription'
export default function MainNoteCard({ export default function MainNoteCard({
event, event,
className, className,
reposter, reposters,
embedded, embedded,
originalNoteId, originalNoteId,
pinned = false pinned = false
}: { }: {
event: Event event: Event
className?: string className?: string
reposter?: string reposters?: string[]
embedded?: boolean embedded?: boolean
originalNoteId?: string originalNoteId?: string
pinned?: boolean pinned?: boolean
@@ -37,7 +37,7 @@ export default function MainNoteCard({
<div className={cn('clickable', embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3')}> <div className={cn('clickable', embedded ? 'p-2 sm:p-3 border rounded-lg' : 'py-3')}>
<Collapsible alwaysExpand={embedded}> <Collapsible alwaysExpand={embedded}>
{pinned && <PinnedButton event={event} />} {pinned && <PinnedButton event={event} />}
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} /> <RepostDescription className={embedded ? '' : 'px-4'} reposters={reposters} />
<Note <Note
className={embedded ? '' : 'px-4'} className={embedded ? '' : 'px-4'}
size={embedded ? 'small' : 'normal'} size={embedded ? 'small' : 'normal'}

View File

@@ -1,23 +1,75 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Repeat2 } from 'lucide-react' import { Repeat2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
/**
* - reposters.length === 1: show "Alice reposted"
* - reposters.length === 2: show "Alice, Bob reposted"
* - reposters.length === 3: show "Alice, Bob, Charlie reposted"
* - reposters.length > 3: show "Alice, Bob, and x others reposted" (with hover card showing avatars of others)
*/
export default function RepostDescription({ export default function RepostDescription({
reposter, reposters,
className className
}: { }: {
reposter?: string | null reposters?: string[]
className?: string className?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
if (!reposter) return null if (!reposters?.length) return null
return ( return (
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}> <div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
<Repeat2 size={16} className="shrink-0" /> <Repeat2 size={16} className="shrink-0" />
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" /> <Username
key={reposters[0]}
userId={reposters[0]}
className={cn('font-semibold truncate', reposters.length > 1 && 'after:content-[","]')}
skeletonClassName="h-3"
/>
{reposters.length > 1 && (
<Username
key={reposters[1]}
userId={reposters[1]}
className={cn('font-semibold truncate', reposters.length === 3 && 'after:content-[","]')}
skeletonClassName="h-3"
/>
)}
{reposters.length > 3 ? (
<AndXOthers reposters={reposters.slice(2)} />
) : reposters.length === 3 ? (
<Username
key={reposters[2]}
userId={reposters[2]}
className={cn('font-semibold truncate')}
skeletonClassName="h-3"
/>
) : null}
<div className="shrink-0">{t('reposted')}</div> <div className="shrink-0">{t('reposted')}</div>
</div> </div>
) )
} }
function AndXOthers({ reposters }: { reposters: string[] }) {
const { t } = useTranslation()
return (
<HoverCard>
<HoverCardTrigger asChild>
<span className="shrink-0 hover:underline">
{t('and {{x}} others', { x: reposters.length })}
</span>
</HoverCardTrigger>
<HoverCardContent className="w-fit max-w-60 flex flex-wrap p-2">
{reposters.map((pubkey) => (
<div key={pubkey} className="p-2">
<UserAvatar key={pubkey} userId={pubkey} size="small" />
</div>
))}
</HoverCardContent>
</HoverCard>
)
}

View File

@@ -1,9 +1,9 @@
import { isMentioningMutedUsers } from '@/lib/event' import { isMentioningMutedUsers } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import client from '@/services/client.service' 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 { useEffect, useMemo, useState } from 'react'
import MainNoteCard from './MainNoteCard' import MainNoteCard from './MainNoteCard'
@@ -51,15 +51,20 @@ export default function RepostNoteCard({
return return
} }
const [, id, relay, , pubkey] = event.tags.find(tagNameEquals('e')) ?? [] let targetEventId: string | undefined
if (!id) { 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 return
} }
const targetEventId = nip19.neventEncode({
id,
relays: relay ? [relay] : [],
author: pubkey
})
const targetEvent = await client.fetchEvent(targetEventId) const targetEvent = await client.fetchEvent(targetEventId)
if (targetEvent) { if (targetEvent) {
setTargetEvent(targetEvent) setTargetEvent(targetEvent)
@@ -76,7 +81,7 @@ export default function RepostNoteCard({
return ( return (
<MainNoteCard <MainNoteCard
className={className} className={className}
reposter={event.pubkey} reposters={[event.pubkey]}
event={targetEvent} event={targetEvent}
pinned={pinned} pinned={pinned}
/> />

View File

@@ -11,12 +11,14 @@ export default function NoteCard({
event, event,
className, className,
filterMutedNotes = true, filterMutedNotes = true,
pinned = false pinned = false,
reposters
}: { }: {
event: Event event: Event
className?: string className?: string
filterMutedNotes?: boolean filterMutedNotes?: boolean
pinned?: boolean pinned?: boolean
reposters?: string[]
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -41,7 +43,7 @@ export default function NoteCard({
/> />
) )
} }
return <MainNoteCard event={event} className={className} pinned={pinned} /> return <MainNoteCard event={event} className={className} pinned={pinned} reposters={reposters} />
} }
export function NoteCardLoadingSkeleton() { export function NoteCardLoadingSkeleton() {

View File

@@ -1,11 +1,12 @@
import NewNotesButton from '@/components/NewNotesButton' import NewNotesButton from '@/components/NewNotesButton'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
getReplaceableCoordinateFromEvent, getEventKey,
getEventKeyFromTag,
isMentioningMutedUsers, isMentioningMutedUsers,
isReplaceableEvent,
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import { tagNameEquals } from '@/lib/tag'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
@@ -15,7 +16,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event } from 'nostr-tools' import { Event, kinds, verifyEvent } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19' import { decode } from 'nostr-tools/nip19'
import { import {
forwardRef, forwardRef,
@@ -113,34 +114,95 @@ const NoteList = forwardRef(
[hideReplies, hideUntrustedNotes, mutePubkeySet, pinnedEventIds, isEventDeleted, filterFn] [hideReplies, hideUntrustedNotes, mutePubkeySet, pinnedEventIds, isEventDeleted, filterFn]
) )
const filteredEvents = useMemo(() => { const filteredNotes = useMemo(() => {
const idSet = new Set<string>() // Store processed event keys to avoid duplicates
const keySet = new Set<string>()
// Map to track reposters for each event key
const repostersMap = new Map<string, Set<string>>()
// Final list of filtered events
const filteredEvents: Event[] = []
return events.slice(0, showCount).filter((evt) => { events.slice(0, showCount).forEach((evt) => {
if (shouldHideEvent(evt)) return false const key = getEventKey(evt)
if (keySet.has(key)) return
keySet.add(key)
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id if (shouldHideEvent(evt)) return
if (idSet.has(id)) { if (evt.kind !== kinds.Repost) {
return false 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]) }, [events, showCount, shouldHideEvent])
const filteredNewEvents = useMemo(() => { const filteredNewEvents = useMemo(() => {
const idSet = new Set<string>() const keySet = new Set<string>()
return newEvents.filter((event: Event) => { return newEvents.filter((event: Event) => {
if (shouldHideEvent(event)) return false if (shouldHideEvent(event)) return false
const id = isReplaceableEvent(event.kind) const key = getEventKey(event)
? getReplaceableCoordinateFromEvent(event) if (keySet.has(key)) {
: event.id
if (idSet.has(id)) {
return false return false
} }
idSet.add(id) keySet.add(key)
return true return true
}) })
}, [newEvents, shouldHideEvent]) }, [newEvents, shouldHideEvent])
@@ -306,12 +368,13 @@ const NoteList = forwardRef(
{pinnedEventIds.map((id) => ( {pinnedEventIds.map((id) => (
<PinnedNoteCard key={id} eventId={id} className="w-full" /> <PinnedNoteCard key={id} eventId={id} className="w-full" />
))} ))}
{filteredEvents.map((event) => ( {filteredNotes.map(({ key, event, reposters }) => (
<NoteCard <NoteCard
key={event.id} key={key}
className="w-full" className="w-full"
event={event} event={event}
filterMutedNotes={filterMutedNotes} filterMutedNotes={filterMutedNotes}
reposters={reposters}
/> />
))} ))}
{hasMore || loading ? ( {hasMore || loading ? (

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.', 'Jumble هو عميل يركز على تصفح المرحلات. ابدأ باستكشاف المرحلات المثيرة للاهتمام أو قم بتسجيل الدخول لعرض خلاصتك.',
'Explore Relays': 'استكشف المرحلات', 'Explore Relays': 'استكشف المرحلات',
'Choose a feed': 'اختر خلاصة' 'Choose a feed': 'اختر خلاصة',
'and {{x}} others': 'و {{x}} آخرون'
} }
} }

View File

@@ -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 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.', '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', 'Explore Relays': 'Relays erkunden',
'Choose a feed': 'Wähle einen Feed' 'Choose a feed': 'Wähle einen Feed',
'and {{x}} others': 'und {{x}} andere'
} }
} }

View File

@@ -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.':
'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', 'Explore Relays': 'Explore Relays',
'Choose a feed': 'Choose a feed' 'Choose a feed': 'Choose a feed',
'and {{x}} others': 'and {{x}} others'
} }
} }

View File

@@ -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 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.', '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', 'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Elige un feed' 'Choose a feed': 'Elige un feed',
'and {{x}} others': 'y {{x}} otros'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble یک کلاینت متمرکز بر مرور رله‌هاست. با کاوش در رله‌های جالب شروع کنید یا وارد شوید تا فید دنبال‌کننده‌های خود را مشاهده کنید.', 'Jumble یک کلاینت متمرکز بر مرور رله‌هاست. با کاوش در رله‌های جالب شروع کنید یا وارد شوید تا فید دنبال‌کننده‌های خود را مشاهده کنید.',
'Explore Relays': 'کاوش در رله‌ها', 'Explore Relays': 'کاوش در رله‌ها',
'Choose a feed': 'یک فید انتخاب کنید' 'Choose a feed': 'یک فید انتخاب کنید',
'and {{x}} others': 'و {{x}} دیگر'
} }
} }

View File

@@ -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 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.", "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', 'Explore Relays': 'Explorer les relais',
'Choose a feed': 'Choisir un fil' 'Choose a feed': 'Choisir un fil',
'and {{x}} others': 'et {{x}} autres'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।', 'Jumble एक क्लाइंट है जो रिले ब्राउज़ करने पर केंद्रित है। रोचक रिले की खोज करके शुरू करें या अपनी फ़ॉलोइंग फ़ीड देखने के लिए लॉगिन करें।',
'Explore Relays': 'रिले एक्सप्लोर करें', 'Explore Relays': 'रिले एक्सप्लोर करें',
'Choose a feed': 'एक फीड चुनें' 'Choose a feed': 'एक फीड चुनें',
'and {{x}} others': 'और {{x}} अन्य'
} }
} }

View File

@@ -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 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.', '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', 'Explore Relays': 'Esplora Relay',
'Choose a feed': 'Scegli un feed' 'Choose a feed': 'Scegli un feed',
'and {{x}} others': 'e altri {{x}}'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumbleはリレーを閲覧することに焦点を当てたクライアントです。興味深いリレーを探索するか、ログインしてフォロー中のフィードを表示してください。', 'Jumbleはリレーを閲覧することに焦点を当てたクライアントです。興味深いリレーを探索するか、ログインしてフォロー中のフィードを表示してください。',
'Explore Relays': 'リレーを探索', 'Explore Relays': 'リレーを探索',
'Choose a feed': 'フィードを選択' 'Choose a feed': 'フィードを選択',
'and {{x}} others': 'および他{{x}}人'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble은 릴레이 탐색에 중점을 둔 클라이언트입니다. 흥미로운 릴레이를 탐색하거나 로그인하여 팔로잉 피드를 확인하세요.', 'Jumble은 릴레이 탐색에 중점을 둔 클라이언트입니다. 흥미로운 릴레이를 탐색하거나 로그인하여 팔로잉 피드를 확인하세요.',
'Explore Relays': '릴레이 탐색', 'Explore Relays': '릴레이 탐색',
'Choose a feed': '피드 선택' 'Choose a feed': '피드 선택',
'and {{x}} others': '및 기타 {{x}}명'
} }
} }

View File

@@ -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 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.', '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', 'Explore Relays': 'Eksploruj Relay',
'Choose a feed': 'Wybierz feed' 'Choose a feed': 'Wybierz feed',
'and {{x}} others': 'i {{x}} innych'
} }
} }

View File

@@ -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 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.', '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', 'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Escolha um feed' 'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros'
} }
} }

View File

@@ -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 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.', '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', 'Explore Relays': 'Explorar Relays',
'Choose a feed': 'Escolha um feed' 'Choose a feed': 'Escolha um feed',
'and {{x}} others': 'e {{x}} outros'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble — это клиент, ориентированный на просмотр relay. Начните с изучения интересных relay или войдите, чтобы увидеть ленту подписок.', 'Jumble — это клиент, ориентированный на просмотр relay. Начните с изучения интересных relay или войдите, чтобы увидеть ленту подписок.',
'Explore Relays': 'Исследовать Relay', 'Explore Relays': 'Исследовать Relay',
'Choose a feed': 'Выберите ленту' 'Choose a feed': 'Выберите ленту',
'and {{x}} others': 'и {{x}} других'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble เป็นไคลเอนต์ที่เน้นการเรียกดูรีเลย์ เริ่มต้นด้วยการสำรวจรีเลย์ที่น่าสนใจ หรือเข้าสู่ระบบเพื่อดูฟีดที่คุณติดตาม', 'Jumble เป็นไคลเอนต์ที่เน้นการเรียกดูรีเลย์ เริ่มต้นด้วยการสำรวจรีเลย์ที่น่าสนใจ หรือเข้าสู่ระบบเพื่อดูฟีดที่คุณติดตาม',
'Explore Relays': 'สำรวจรีเลย์', 'Explore Relays': 'สำรวจรีเลย์',
'Choose a feed': 'เลือกฟีด' 'Choose a feed': 'เลือกฟีด',
'and {{x}} others': 'และอื่น ๆ {{x}} รายการ'
} }
} }

View File

@@ -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 is a client focused on browsing relays. Get started by exploring interesting relays or login to view your following feed.':
'Jumble 是一个专注于浏览服务器的客户端。从探索有趣的服务器开始,或者登录查看你的关注动态。', 'Jumble 是一个专注于浏览服务器的客户端。从探索有趣的服务器开始,或者登录查看你的关注动态。',
'Explore Relays': '探索服务器', 'Explore Relays': '探索服务器',
'Choose a feed': '选择一个动态' 'Choose a feed': '选择一个动态',
'and {{x}} others': '和其他 {{x}} 人'
} }
} }