diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx
index 4e9ae39c..c4c8103f 100644
--- a/src/components/KindFilter/index.tsx
+++ b/src/components/KindFilter/index.tsx
@@ -84,7 +84,7 @@ export default function KindFilter({
variant="ghost"
size="titlebar-icon"
className={cn(
- 'relative w-fit px-3 focus:text-foreground',
+ 'relative w-fit px-3 hover:text-foreground',
!isDifferentFromSaved && 'text-muted-foreground'
)}
onClick={() => {
@@ -94,7 +94,6 @@ export default function KindFilter({
}}
>
- {t('Filter')}
{isDifferentFromSaved && (
)}
diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx
index 734b3765..22fd2b1d 100644
--- a/src/components/NormalFeed/index.tsx
+++ b/src/components/NormalFeed/index.tsx
@@ -1,10 +1,12 @@
import NoteList, { TNoteListRef } from '@/components/NoteList'
import Tabs from '@/components/Tabs'
+import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList'
import { isTouchDevice } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types'
+import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react'
import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton'
@@ -13,12 +15,16 @@ export default function NormalFeed({
subRequests,
areAlgoRelays = false,
isMainFeed = false,
- showRelayCloseReason = false
+ showRelayCloseReason = false,
+ filterFn,
+ disable24hMode = false
}: {
subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean
isMainFeed?: boolean
showRelayCloseReason?: boolean
+ filterFn?: (event: Event) => boolean
+ disable24hMode?: boolean
}) {
const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter()
@@ -26,13 +32,18 @@ export default function NormalFeed({
const [listMode, setListMode] = useState(() => storage.getNoteListMode())
const supportTouch = useMemo(() => isTouchDevice(), [])
const noteListRef = useRef(null)
+ const userAggregationListRef = useRef(null)
+ const topRef = useRef(null)
+ const showKindsFilter = useMemo(() => {
+ return subRequests.every((req) => !req.filter.kinds?.length)
+ }, [subRequests])
const handleListModeChange = (mode: TNoteListMode) => {
setListMode(mode)
if (isMainFeed) {
storage.setNoteListMode(mode)
}
- noteListRef.current?.scrollToTop('smooth')
+ topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const handleShowKindsChange = (newShowKinds: number[]) => {
@@ -43,30 +54,56 @@ export default function NormalFeed({
return (
<>
{
handleListModeChange(listMode as TNoteListMode)
}}
options={
<>
- {!supportTouch && noteListRef.current?.refresh()} />}
-
+ {!supportTouch && (
+ {
+ if (listMode === '24h') {
+ userAggregationListRef.current?.refresh()
+ } else {
+ noteListRef.current?.refresh()
+ }
+ }}
+ />
+ )}
+ {showKindsFilter && (
+
+ )}
>
}
/>
-
+
+ {listMode === '24h' && !disable24hMode ? (
+
+ ) : (
+
+ )}
>
)
}
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx
index 6621a82f..7e69ef97 100644
--- a/src/components/NoteList/index.tsx
+++ b/src/components/NoteList/index.tsx
@@ -33,7 +33,26 @@ const LIMIT = 200
const ALGO_LIMIT = 500
const SHOW_COUNT = 10
-const NoteList = forwardRef(
+export type TNoteListRef = {
+ scrollToTop: (behavior?: ScrollBehavior) => void
+ refresh: () => void
+}
+
+const NoteList = forwardRef<
+ TNoteListRef,
+ {
+ subRequests: TFeedSubRequest[]
+ showKinds?: number[]
+ filterMutedNotes?: boolean
+ hideReplies?: boolean
+ hideUntrustedNotes?: boolean
+ areAlgoRelays?: boolean
+ showRelayCloseReason?: boolean
+ pinnedEventIds?: string[]
+ filterFn?: (event: Event) => boolean
+ showNewNotesDirectly?: boolean
+ }
+>(
(
{
subRequests,
@@ -46,17 +65,6 @@ const NoteList = forwardRef(
pinnedEventIds,
filterFn,
showNewNotesDirectly = false
- }: {
- subRequests: TFeedSubRequest[]
- showKinds?: number[]
- filterMutedNotes?: boolean
- hideReplies?: boolean
- hideUntrustedNotes?: boolean
- areAlgoRelays?: boolean
- showRelayCloseReason?: boolean
- pinnedEventIds?: string[]
- filterFn?: (event: Event) => boolean
- showNewNotesDirectly?: boolean
},
ref
) => {
@@ -415,8 +423,3 @@ const NoteList = forwardRef(
)
NoteList.displayName = 'NoteList'
export default NoteList
-
-export type TNoteListRef = {
- scrollToTop: (behavior?: ScrollBehavior) => void
- refresh: () => void
-}
diff --git a/src/components/Profile/ProfileFeed.tsx b/src/components/Profile/ProfileFeed.tsx
index 8bc589ad..363f7d79 100644
--- a/src/components/Profile/ProfileFeed.tsx
+++ b/src/components/Profile/ProfileFeed.tsx
@@ -26,7 +26,13 @@ export default function ProfileFeed({
const { pubkey: myPubkey, pinListEvent: myPinListEvent } = useNostr()
const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
- const [listMode, setListMode] = useState(() => storage.getNoteListMode())
+ const [listMode, setListMode] = useState(() => {
+ const mode = storage.getNoteListMode()
+ if (mode === '24h') {
+ return 'posts'
+ }
+ return mode
+ })
const [subRequests, setSubRequests] = useState([])
const [pinnedEventIds, setPinnedEventIds] = useState([])
const tabs = useMemo(() => {
diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx
new file mode 100644
index 00000000..f4593589
--- /dev/null
+++ b/src/components/UserAggregationList/index.tsx
@@ -0,0 +1,466 @@
+import { FormattedTimestamp } from '@/components/FormattedTimestamp'
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import UserAvatar from '@/components/UserAvatar'
+import Username from '@/components/Username'
+import { isMentioningMutedUsers } from '@/lib/event'
+import { toNote, toUserAggregationDetail } from '@/lib/link'
+import { cn, isTouchDevice } from '@/lib/utils'
+import { useSecondaryPage } from '@/PageManager'
+import { useContentPolicy } from '@/providers/ContentPolicyProvider'
+import { useDeletedEvent } from '@/providers/DeletedEventProvider'
+import { useMuteList } from '@/providers/MuteListProvider'
+import { useNostr } from '@/providers/NostrProvider'
+import { useUserTrust } from '@/providers/UserTrustProvider'
+import client from '@/services/client.service'
+import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
+import { TFeedSubRequest } from '@/types'
+import dayjs from 'dayjs'
+import { History, Loader, Pin, PinOff } from 'lucide-react'
+import { Event, kinds } from 'nostr-tools'
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import PullToRefresh from 'react-simple-pull-to-refresh'
+import { LoadingBar } from '../LoadingBar'
+
+const LIMIT = 500
+const SHOW_COUNT = 20
+
+export type TUserAggregationListRef = {
+ scrollToTop: (behavior?: ScrollBehavior) => void
+ refresh: () => void
+}
+
+const UserAggregationList = forwardRef<
+ TUserAggregationListRef,
+ {
+ subRequests: TFeedSubRequest[]
+ showKinds?: number[]
+ filterFn?: (event: Event) => boolean
+ filterMutedNotes?: boolean
+ }
+>(({ subRequests, showKinds, filterFn, filterMutedNotes = true }, ref) => {
+ const { t } = useTranslation()
+ const { startLogin } = useNostr()
+ const { push } = useSecondaryPage()
+ const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
+ const { mutePubkeySet } = useMuteList()
+ const { hideContentMentioningMutedUsers } = useContentPolicy()
+ const { isEventDeleted } = useDeletedEvent()
+ const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
+ const [events, setEvents] = useState([])
+ const [timelineKey, setTimelineKey] = useState(undefined)
+ const [loading, setLoading] = useState(true)
+ const [showLoadingBar, setShowLoadingBar] = useState(true)
+ const [refreshCount, setRefreshCount] = useState(0)
+ const [showCount, setShowCount] = useState(SHOW_COUNT)
+ const supportTouch = useMemo(() => isTouchDevice(), [])
+ const [pinnedPubkeys, setPinnedPubkeys] = useState>(
+ new Set(userAggregationService.getPinnedPubkeys())
+ )
+ const feedId = useMemo(() => {
+ return userAggregationService.getFeedId(subRequests, showKinds)
+ }, [JSON.stringify(subRequests), JSON.stringify(showKinds)])
+ const bottomRef = useRef(null)
+ const topRef = useRef(null)
+
+ const scrollToTop = (behavior: ScrollBehavior = 'instant') => {
+ setTimeout(() => {
+ topRef.current?.scrollIntoView({ behavior, block: 'start' })
+ }, 20)
+ }
+
+ const refresh = () => {
+ scrollToTop()
+ setTimeout(() => {
+ setRefreshCount((count) => count + 1)
+ }, 500)
+ }
+
+ useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [])
+
+ useEffect(() => {
+ return () => {
+ userAggregationService.clearAggregations(feedId)
+ }
+ }, [feedId])
+
+ useEffect(() => {
+ if (!subRequests.length) return
+
+ setPinnedPubkeys(new Set(userAggregationService.getPinnedPubkeys()))
+ setSince(dayjs().subtract(1, 'day').unix())
+
+ async function init() {
+ setLoading(true)
+ setEvents([])
+
+ if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
+ setLoading(false)
+ return () => {}
+ }
+
+ const { closer, timelineKey } = await client.subscribeTimeline(
+ subRequests.map(({ urls, filter }) => ({
+ urls,
+ filter: {
+ kinds: showKinds ?? [],
+ ...filter,
+ limit: LIMIT
+ }
+ })),
+ {
+ onEvents: (events, eosed) => {
+ if (events.length > 0) {
+ setEvents(events)
+ }
+ if (eosed) {
+ setLoading(false)
+ }
+ },
+ onNew: (event) => {
+ setEvents((oldEvents) => {
+ const newEvents = oldEvents.some((e) => e.id === event.id)
+ ? oldEvents
+ : [event, ...oldEvents]
+ return newEvents
+ })
+ }
+ },
+ {
+ startLogin,
+ needSort: true
+ }
+ )
+ setTimelineKey(timelineKey)
+
+ return closer
+ }
+
+ const promise = init()
+ return () => {
+ promise.then((closer) => closer())
+ }
+ }, [feedId, refreshCount])
+
+ useEffect(() => {
+ if (
+ loading ||
+ !timelineKey ||
+ !events.length ||
+ events[events.length - 1].created_at <= since
+ ) {
+ return
+ }
+
+ const until = events[events.length - 1].created_at - 1
+
+ setLoading(true)
+ client.loadMoreTimeline(timelineKey, until, LIMIT).then((moreEvents) => {
+ setEvents((oldEvents) => [...oldEvents, ...moreEvents])
+ setLoading(false)
+ })
+ }, [loading, timelineKey, events, since])
+
+ useEffect(() => {
+ if (loading) {
+ setShowLoadingBar(true)
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ setShowLoadingBar(false)
+ }, 1000)
+
+ return () => clearTimeout(timeout)
+ }, [loading])
+
+ const shouldHideEvent = useCallback(
+ (evt: Event) => {
+ if (isEventDeleted(evt)) return true
+ if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return true
+ if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true
+ if (
+ filterMutedNotes &&
+ hideContentMentioningMutedUsers &&
+ isMentioningMutedUsers(evt, mutePubkeySet)
+ ) {
+ return true
+ }
+ if (filterFn && !filterFn(evt)) {
+ return true
+ }
+
+ return false
+ },
+ [hideUntrustedNotes, mutePubkeySet, isEventDeleted, filterFn]
+ )
+
+ const lastXDays = useMemo(() => {
+ return dayjs().diff(dayjs.unix(since), 'day')
+ }, [since])
+
+ const filteredEvents = useMemo(() => {
+ return events.filter((evt) => evt.created_at >= since && !shouldHideEvent(evt))
+ }, [events, since, shouldHideEvent])
+
+ const aggregations = useMemo(() => {
+ const aggs = userAggregationService.aggregateByUser(filteredEvents)
+ userAggregationService.saveAggregations(feedId, aggs)
+
+ const pinned: TUserAggregation[] = []
+ const unpinned: TUserAggregation[] = []
+
+ aggs.forEach((agg) => {
+ if (pinnedPubkeys.has(agg.pubkey)) {
+ pinned.push(agg)
+ } else {
+ unpinned.push(agg)
+ }
+ })
+
+ return [...pinned, ...unpinned]
+ }, [feedId, filteredEvents, pinnedPubkeys])
+
+ const displayedAggregations = useMemo(() => {
+ return aggregations.slice(0, showCount)
+ }, [aggregations, showCount])
+
+ const hasMore = useMemo(() => {
+ return aggregations.length > displayedAggregations.length
+ }, [aggregations, displayedAggregations])
+
+ useEffect(() => {
+ const options = {
+ root: null,
+ rootMargin: '10px',
+ threshold: 1
+ }
+ if (!hasMore) return
+
+ const observerInstance = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) {
+ setShowCount((count) => count + SHOW_COUNT)
+ }
+ }, options)
+
+ const currentBottomRef = bottomRef.current
+ if (currentBottomRef) {
+ observerInstance.observe(currentBottomRef)
+ }
+
+ return () => {
+ if (observerInstance && currentBottomRef) {
+ observerInstance.unobserve(currentBottomRef)
+ }
+ }
+ }, [hasMore])
+
+ const handleViewUser = (agg: TUserAggregation) => {
+ // Mark as viewed when user clicks
+ userAggregationService.markAsViewed(feedId, agg.pubkey)
+
+ if (agg.count === 1) {
+ const evt = agg.events[0]
+ if (evt.kind !== kinds.Repost && evt.kind !== kinds.GenericRepost) {
+ push(toNote(agg.events[0]))
+ return
+ }
+ }
+
+ push(toUserAggregationDetail(feedId, agg.pubkey))
+ }
+
+ const handleLoadEarlier = () => {
+ setSince((prevSince) => dayjs.unix(prevSince).subtract(1, 'day').unix())
+ setShowCount(SHOW_COUNT)
+ }
+
+ const list = (
+
+ {displayedAggregations.map((agg) => (
+
handleViewUser(agg)}
+ />
+ ))}
+ {loading || hasMore ? (
+
+
+
+ ) : displayedAggregations.length === 0 ? (
+
+
+
+ ) : (
+ {t('no more notes')}
+ )}
+
+ )
+
+ return (
+
+
+ {showLoadingBar &&
}
+
+
+
+ {lastXDays === 1 ? t('Last 24 hours') : t('Last {{count}} days', { count: lastXDays })}
+
+ ·
+
+ {filteredEvents.length} {t('notes')}
+
+
+
+
+ {supportTouch ? (
+
{
+ refresh()
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ }}
+ pullingContent=""
+ >
+ {list}
+
+ ) : (
+ list
+ )}
+
+ )
+})
+UserAggregationList.displayName = 'UserAggregationList'
+export default UserAggregationList
+
+function UserAggregationItem({
+ feedId,
+ aggregation,
+ onClick
+}: {
+ feedId: string
+ aggregation: TUserAggregation
+ onClick: () => void
+}) {
+ const { t } = useTranslation()
+ const [hasNewEvents, setHasNewEvents] = useState(true)
+ const [isPinned, setIsPinned] = useState(userAggregationService.isPinned(aggregation.pubkey))
+
+ useEffect(() => {
+ const update = () => {
+ const lastViewedTime = userAggregationService.getLastViewedTime(feedId, aggregation.pubkey)
+ setHasNewEvents(aggregation.lastEventTime > lastViewedTime)
+ }
+
+ const unSub = userAggregationService.subscribeViewedTimeChange(
+ feedId,
+ aggregation.pubkey,
+ () => {
+ update()
+ }
+ )
+
+ update()
+
+ return unSub
+ }, [feedId, aggregation])
+
+ const onTogglePin = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (isPinned) {
+ userAggregationService.unpinUser(aggregation.pubkey)
+ setIsPinned(false)
+ } else {
+ userAggregationService.pinUser(aggregation.pubkey)
+ setIsPinned(true)
+ }
+ }
+
+ const onToggleViewed = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (hasNewEvents) {
+ userAggregationService.markAsViewed(feedId, aggregation.pubkey)
+ } else {
+ userAggregationService.markAsUnviewed(feedId, aggregation.pubkey)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function UserAggregationItemSkeleton() {
+ return (
+
+ )
+}
diff --git a/src/constants.ts b/src/constants.ts
index 089782d4..db8b800d 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -41,6 +41,7 @@ export const StorageKey = {
ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout',
FAVICON_URL_TEMPLATE: 'faviconUrlTemplate',
FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays',
+ PINNED_PUBKEYS: 'pinnedPubkeys',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts
index 713345dc..9c4e0755 100644
--- a/src/i18n/locales/ar.ts
+++ b/src/i18n/locales/ar.ts
@@ -547,11 +547,13 @@ export default {
'Optimal relays': 'المرحلات المثلى',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'تم إعادة النشر بنجاح إلى المرحلات المثلى (مرحلات الكتابة الخاصة بك ومرحلات القراءة للمستخدمين المذكورين)',
- 'Failed to republish to optimal relays: {{error}}': 'فشل إعادة النشر إلى المرحلات المثلى: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'فشل إعادة النشر إلى المرحلات المثلى: {{error}}',
'External Content': 'محتوى خارجي',
Highlight: 'تسليط الضوء',
'Optimal relays and {{count}} other relays': 'المرحلات المثلى و {{count}} مرحلات أخرى',
- 'Likely spam account (Trust score: {{percentile}}%)': 'حساب مشبوه للغاية (درجة الثقة: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'حساب مشبوه للغاية (درجة الثقة: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشبوه (درجة الثقة: {{percentile}}%)',
'n users': '{{count}} مستخدمين',
'View Details': 'عرض التفاصيل',
@@ -559,6 +561,11 @@ export default {
'Follow pack not found': 'لم يتم العثور على حزمة المتابعة',
Users: 'المستخدمون',
Feed: 'التغذية',
- 'Follow Pack': 'حزمة المتابعة'
+ 'Follow Pack': 'حزمة المتابعة',
+ '24h Pulse': 'النبض 24 ساعة',
+ 'Load earlier': 'تحميل سابق',
+ 'Last 24 hours': 'آخر 24 ساعة',
+ 'Last {{count}} days': 'آخر {{count}} أيام',
+ notes: 'ملاحظات'
}
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 88cdca64..21736bc1 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -567,14 +567,21 @@ export default {
'External Content': 'Externer Inhalt',
Highlight: 'Hervorheben',
'Optimal relays and {{count}} other relays': 'Optimale Relays und {{count}} andere Relays',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)',
'n users': '{{count}} Benutzer',
'View Details': 'Details anzeigen',
'Follow Pack Not Found': 'Follow-Pack nicht gefunden',
'Follow pack not found': 'Follow-Pack nicht gefunden',
Users: 'Benutzer',
Feed: 'Feed',
- 'Follow Pack': 'Follow-Pack'
+ 'Follow Pack': 'Follow-Pack',
+ '24h Pulse': '24h Pulse',
+ 'Load earlier': 'Früher laden',
+ 'Last 24 hours': 'Letzte 24 Stunden',
+ 'Last {{count}} days': 'Letzte {{count}} Tage',
+ notes: 'Notizen'
}
}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 54851e2d..003ec422 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -564,6 +564,11 @@ export default {
'Follow pack not found': 'Follow pack not found',
Users: 'Users',
Feed: 'Feed',
- 'Follow Pack': 'Follow Pack'
+ 'Follow Pack': 'Follow Pack',
+ '24h Pulse': '24h Pulse',
+ 'Load earlier': 'Load earlier',
+ 'Last 24 hours': 'Last 24 hours',
+ 'Last {{count}} days': 'Last {{count}} days',
+ notes: 'notes'
}
}
diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts
index 8358dab0..9f4222c7 100644
--- a/src/i18n/locales/es.ts
+++ b/src/i18n/locales/es.ts
@@ -558,18 +558,26 @@ export default {
'Optimal relays': 'Relays óptimos',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'Republicado exitosamente en relays óptimos (tus relays de escritura y los relays de lectura de los usuarios mencionados)',
- 'Failed to republish to optimal relays: {{error}}': 'Error al republicar en relays óptimos: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'Error al republicar en relays óptimos: {{error}}',
'External Content': 'Contenido externo',
Highlight: 'Destacado',
'Optimal relays and {{count}} other relays': 'Relays óptimos y {{count}} otros relays',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Probablemente cuenta spam (Puntuación de confianza: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Cuenta sospechosa (Puntuación de confianza: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Probablemente cuenta spam (Puntuación de confianza: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Cuenta sospechosa (Puntuación de confianza: {{percentile}}%)',
'n users': '{{count}} usuarios',
'View Details': 'Ver detalles',
'Follow Pack Not Found': 'Paquete de seguimiento no encontrado',
'Follow pack not found': 'Paquete de seguimiento no encontrado',
Users: 'Usuarios',
Feed: 'Feed',
- 'Follow Pack': 'Paquete de Seguimiento'
+ 'Follow Pack': 'Paquete de Seguimiento',
+ '24h Pulse': 'Pulso 24h',
+ 'Load earlier': 'Cargar anterior',
+ 'Last 24 hours': 'Últimas 24 horas',
+ 'Last {{count}} days': 'Últimos {{count}} días',
+ notes: 'notas'
}
}
diff --git a/src/i18n/locales/fa.ts b/src/i18n/locales/fa.ts
index 8e77229f..60ed6859 100644
--- a/src/i18n/locales/fa.ts
+++ b/src/i18n/locales/fa.ts
@@ -552,18 +552,26 @@ export default {
'Optimal relays': 'رلههای بهینه',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'با موفقیت در رلههای بهینه منتشر شد (رلههای نوشتن شما و رلههای خواندن کاربران ذکر شده)',
- 'Failed to republish to optimal relays: {{error}}': 'خطا در انتشار مجدد در رلههای بهینه: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'خطا در انتشار مجدد در رلههای بهینه: {{error}}',
'External Content': 'محتوای خارجی',
Highlight: 'برجستهسازی',
'Optimal relays and {{count}} other relays': 'رلههای بهینه و {{count}} رله دیگر',
- 'Likely spam account (Trust score: {{percentile}}%)': 'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)',
'n users': '{{count}} کاربر',
'View Details': 'مشاهده جزئیات',
'Follow Pack Not Found': 'بسته دنبالکننده یافت نشد',
'Follow pack not found': 'بسته دنبالکننده یافت نشد',
Users: 'کاربران',
Feed: 'فید',
- 'Follow Pack': 'بسته دنبالکننده'
+ 'Follow Pack': 'بسته دنبالکننده',
+ '24h Pulse': 'نبض 24 ساعته',
+ 'Load earlier': 'بارگذاری قدیمیتر',
+ 'Last 24 hours': '24 ساعت گذشته',
+ 'Last {{count}} days': '{{count}} روز گذشته',
+ notes: 'یادداشتها'
}
}
diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts
index 6fc27f6f..2a8ebb48 100644
--- a/src/i18n/locales/fr.ts
+++ b/src/i18n/locales/fr.ts
@@ -561,18 +561,26 @@ export default {
'Optimal relays': 'Relais optimaux',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
"Republié avec succès sur les relais optimaux (vos relais d'écriture et les relais de lecture des utilisateurs mentionnés)",
- 'Failed to republish to optimal relays: {{error}}': 'Échec de la republication sur les relais optimaux : {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'Échec de la republication sur les relais optimaux : {{error}}',
'External Content': 'Contenu externe',
Highlight: 'Surligner',
'Optimal relays and {{count}} other relays': 'Relais optimaux et {{count}} autres relais',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Compte probablement spam (Score de confiance: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Compte suspect (Score de confiance: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Compte probablement spam (Score de confiance: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Compte suspect (Score de confiance: {{percentile}}%)',
'n users': '{{count}} utilisateurs',
'View Details': 'Voir les détails',
'Follow Pack Not Found': 'Pack de suivi introuvable',
'Follow pack not found': 'Pack de suivi introuvable',
Users: 'Utilisateurs',
Feed: 'Flux',
- 'Follow Pack': 'Pack de Suivi'
+ 'Follow Pack': 'Pack de Suivi',
+ '24h Pulse': 'Pulse 24h',
+ 'Load earlier': 'Charger plus tôt',
+ 'Last 24 hours': 'Dernières 24 heures',
+ 'Last {{count}} days': 'Derniers {{count}} jours',
+ notes: 'notes'
}
}
diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts
index c70b4a30..b1ec9e26 100644
--- a/src/i18n/locales/hi.ts
+++ b/src/i18n/locales/hi.ts
@@ -553,18 +553,26 @@ export default {
'Optimal relays': 'इष्टतम रिले',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'इष्टतम रिले पर सफलतापूर्वक पुनः प्रकाशित (आपके लेखन रिले और उल्लिखित उपयोगकर्ताओं के पठन रिले)',
- 'Failed to republish to optimal relays: {{error}}': 'इष्टतम रिले पर पुनः प्रकाशित करने में विफल: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'इष्टतम रिले पर पुनः प्रकाशित करने में विफल: {{error}}',
'External Content': 'बाहरी सामग्री',
Highlight: 'हाइलाइट',
'Optimal relays and {{count}} other relays': 'इष्टतम रिले और {{count}} अन्य रिले',
- 'Likely spam account (Trust score: {{percentile}}%)': 'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)',
'n users': '{{count}} उपयोगकर्ता',
'View Details': 'विवरण देखें',
'Follow Pack Not Found': 'फॉलो पैक नहीं मिला',
'Follow pack not found': 'फॉलो पैक नहीं मिला',
Users: 'उपयोगकर्ता',
Feed: 'फ़ीड',
- 'Follow Pack': 'फॉलो पैक'
+ 'Follow Pack': 'फॉलो पैक',
+ '24h Pulse': '24h पल्स',
+ 'Load earlier': 'पहले लोड करें',
+ 'Last 24 hours': 'पिछले 24 घंटे',
+ 'Last {{count}} days': 'पिछले {{count}} दिन',
+ notes: 'नोट्स'
}
}
diff --git a/src/i18n/locales/hu.ts b/src/i18n/locales/hu.ts
index 6a37db40..2e16a040 100644
--- a/src/i18n/locales/hu.ts
+++ b/src/i18n/locales/hu.ts
@@ -552,14 +552,21 @@ export default {
'External Content': 'Külső tartalom',
Highlight: 'Kiemelés',
'Optimal relays and {{count}} other relays': 'Optimális relay-ek és {{count}} másik relay',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Valószínűleg spam fiók (Megbízhatósági pontszám: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Gyanús fiók (Megbízhatósági pontszám: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Valószínűleg spam fiók (Megbízhatósági pontszám: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Gyanús fiók (Megbízhatósági pontszám: {{percentile}}%)',
'n users': '{{count}} felhasználó',
'View Details': 'Részletek megtekintése',
'Follow Pack Not Found': 'Követési csomag nem található',
'Follow pack not found': 'Követési csomag nem található',
Users: 'Felhasználók',
Feed: 'Hírfolyam',
- 'Follow Pack': 'Követési Csomag'
+ 'Follow Pack': 'Követési Csomag',
+ '24h Pulse': '24h Pulse',
+ 'Load earlier': 'Korábbi betöltése',
+ 'Last 24 hours': 'Utolsó 24 óra',
+ 'Last {{count}} days': 'Utolsó {{count}} nap',
+ notes: 'jegyzetek'
}
}
diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts
index 7eeb32c2..4fa542ed 100644
--- a/src/i18n/locales/it.ts
+++ b/src/i18n/locales/it.ts
@@ -557,18 +557,26 @@ export default {
'Optimal relays': 'Relay ottimali',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'Ripubblicato con successo sui relay ottimali (i tuoi relay di scrittura e i relay di lettura degli utenti menzionati)',
- 'Failed to republish to optimal relays: {{error}}': 'Errore nella ripubblicazione sui relay ottimali: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'Errore nella ripubblicazione sui relay ottimali: {{error}}',
'External Content': 'Contenuto esterno',
Highlight: 'Evidenzia',
'Optimal relays and {{count}} other relays': 'Relay ottimali e {{count}} altri relay',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Probabile account spam (Punteggio di fiducia: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Account sospetto (Punteggio di fiducia: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Probabile account spam (Punteggio di fiducia: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Account sospetto (Punteggio di fiducia: {{percentile}}%)',
'n users': '{{count}} utenti',
'View Details': 'Visualizza dettagli',
'Follow Pack Not Found': 'Pacchetto di follow non trovato',
'Follow pack not found': 'Pacchetto di follow non trovato',
Users: 'Utenti',
Feed: 'Feed',
- 'Follow Pack': 'Pacchetto di Follow'
+ 'Follow Pack': 'Pacchetto di Follow',
+ '24h Pulse': 'Pulse 24h',
+ 'Load earlier': 'Carica precedente',
+ 'Last 24 hours': 'Ultime 24 ore',
+ 'Last {{count}} days': 'Ultimi {{count}} giorni',
+ notes: 'note'
}
}
diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts
index 0d20d9bf..2a22e3bc 100644
--- a/src/i18n/locales/ja.ts
+++ b/src/i18n/locales/ja.ts
@@ -552,18 +552,26 @@ export default {
'Optimal relays': '最適なリレー',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'最適なリレー(あなたの書き込みリレーと言及されたユーザーの読み取りリレー)への再公開に成功しました',
- 'Failed to republish to optimal relays: {{error}}': '最適なリレーへの再公開に失敗しました:{{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ '最適なリレーへの再公開に失敗しました:{{error}}',
'External Content': '外部コンテンツ',
Highlight: 'ハイライト',
'Optimal relays and {{count}} other relays': '最適なリレーと他の{{count}}個のリレー',
- 'Likely spam account (Trust score: {{percentile}}%)': 'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': '疑わしいアカウント(信頼スコア:{{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ '疑わしいアカウント(信頼スコア:{{percentile}}%)',
'n users': '{{count}}人のユーザー',
'View Details': '詳細を表示',
'Follow Pack Not Found': 'フォローパックが見つかりません',
'Follow pack not found': 'フォローパックが見つかりません',
Users: 'ユーザー',
Feed: 'フィード',
- 'Follow Pack': 'フォローパック'
+ 'Follow Pack': 'フォローパック',
+ '24h Pulse': '24h パルス',
+ 'Load earlier': '以前を読み込む',
+ 'Last 24 hours': '過去24時間',
+ 'Last {{count}} days': '過去{{count}}日間',
+ notes: 'ノート'
}
}
diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts
index 3258d424..99722a38 100644
--- a/src/i18n/locales/ko.ts
+++ b/src/i18n/locales/ko.ts
@@ -556,14 +556,21 @@ export default {
'External Content': '외부 콘텐츠',
Highlight: '하이라이트',
'Optimal relays and {{count}} other relays': '최적 릴레이 및 기타 {{count}}개 릴레이',
- 'Likely spam account (Trust score: {{percentile}}%)': '스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': '의심스러운 계정 (신뢰 점수: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ '스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ '의심스러운 계정 (신뢰 점수: {{percentile}}%)',
'n users': '{{count}}명의 사용자',
'View Details': '세부 정보 보기',
'Follow Pack Not Found': '팔로우 팩을 찾을 수 없음',
'Follow pack not found': '팔로우 팩을 찾을 수 없습니다',
Users: '사용자',
Feed: '피드',
- 'Follow Pack': '팔로우 팩'
+ 'Follow Pack': '팔로우 팩',
+ '24h Pulse': '24h 펄스',
+ 'Load earlier': '이전 데이터 로드',
+ 'Last 24 hours': '최근 24시간',
+ 'Last {{count}} days': '최근 {{count}}일',
+ notes: '노트'
}
}
diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts
index 2c329d1b..5ff45fb8 100644
--- a/src/i18n/locales/pl.ts
+++ b/src/i18n/locales/pl.ts
@@ -561,15 +561,23 @@ export default {
'Nie udało się opublikować ponownie na optymalnych przekaźnikach: {{error}}',
'External Content': 'Treść zewnętrzna',
Highlight: 'Podświetl',
- 'Optimal relays and {{count}} other relays': 'Optymalne przekaźniki i {{count}} innych przekaźników',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Prawdopodobnie konto spamowe (Wynik zaufania: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Podejrzane konto (Wynik zaufania: {{percentile}}%)',
+ 'Optimal relays and {{count}} other relays':
+ 'Optymalne przekaźniki i {{count}} innych przekaźników',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Prawdopodobnie konto spamowe (Wynik zaufania: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Podejrzane konto (Wynik zaufania: {{percentile}}%)',
'n users': '{{count}} użytkowników',
'View Details': 'Zobacz szczegóły',
'Follow Pack Not Found': 'Nie znaleziono pakietu obserwowanych',
'Follow pack not found': 'Nie znaleziono pakietu obserwowanych',
Users: 'Użytkownicy',
Feed: 'Kanał',
- 'Follow Pack': 'Pakiet Obserwowanych'
+ 'Follow Pack': 'Pakiet Obserwowanych',
+ '24h Pulse': '24h Pulse',
+ 'Load earlier': 'Załaduj wcześniejsze',
+ 'Last 24 hours': 'Ostatnie 24 godziny',
+ 'Last {{count}} days': 'Ostatnie {{count}} dni',
+ notes: 'notatki'
}
}
diff --git a/src/i18n/locales/pt-BR.ts b/src/i18n/locales/pt-BR.ts
index 615bad3a..117328b4 100644
--- a/src/i18n/locales/pt-BR.ts
+++ b/src/i18n/locales/pt-BR.ts
@@ -553,18 +553,26 @@ export default {
'Optimal relays': 'Relays ideais',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'Republicado com sucesso nos relays ideais (seus relays de escrita e os relays de leitura dos usuários mencionados)',
- 'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'Falha ao republicar nos relays ideais: {{error}}',
'External Content': 'Conteúdo externo',
Highlight: 'Marcação',
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
'n users': '{{count}} usuários',
'View Details': 'Ver detalhes',
'Follow Pack Not Found': 'Pacote de seguir não encontrado',
'Follow pack not found': 'Pacote de seguir não encontrado',
Users: 'Usuários',
Feed: 'Feed',
- 'Follow Pack': 'Pacote de Seguir'
+ 'Follow Pack': 'Pacote de Seguir',
+ '24h Pulse': 'Pulso 24h',
+ 'Load earlier': 'Carregar anterior',
+ 'Last 24 hours': 'Últimas 24 horas',
+ 'Last {{count}} days': 'Últimos {{count}} dias',
+ notes: 'notas'
}
}
diff --git a/src/i18n/locales/pt-PT.ts b/src/i18n/locales/pt-PT.ts
index 96b3ee9d..fef840a6 100644
--- a/src/i18n/locales/pt-PT.ts
+++ b/src/i18n/locales/pt-PT.ts
@@ -556,18 +556,26 @@ export default {
'Optimal relays': 'Relays ideais',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'Republicado com sucesso nos relays ideais (os seus relays de escrita e os relays de leitura dos utilizadores mencionados)',
- 'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'Falha ao republicar nos relays ideais: {{error}}',
'External Content': 'Conteúdo externo',
Highlight: 'Destacar',
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
'n users': '{{count}} utilizadores',
'View Details': 'Ver detalhes',
'Follow Pack Not Found': 'Pacote de seguir não encontrado',
'Follow pack not found': 'Pacote de seguir não encontrado',
Users: 'Utilizadores',
Feed: 'Feed',
- 'Follow Pack': 'Pacote de Seguir'
+ 'Follow Pack': 'Pacote de Seguir',
+ '24h Pulse': 'Pulso 24h',
+ 'Load earlier': 'Carregar anterior',
+ 'Last 24 hours': 'Últimas 24 horas',
+ 'Last {{count}} days': 'Últimos {{count}} dias',
+ notes: 'notas'
}
}
diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts
index 2e446874..8dc2c17a 100644
--- a/src/i18n/locales/ru.ts
+++ b/src/i18n/locales/ru.ts
@@ -558,18 +558,26 @@ export default {
'Optimal relays': 'Оптимальные релеи',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'Успешно опубликовано в оптимальные релеи (ваши релеи для записи и релеи для чтения упомянутых пользователей)',
- 'Failed to republish to optimal relays: {{error}}': 'Не удалось опубликовать в оптимальные релеи: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'Не удалось опубликовать в оптимальные релеи: {{error}}',
'External Content': 'Внешний контент',
Highlight: 'Выделить',
'Optimal relays and {{count}} other relays': 'Оптимальные релеи и {{count}} других релеев',
- 'Likely spam account (Trust score: {{percentile}}%)': 'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)',
'n users': '{{count}} пользователей',
'View Details': 'Посмотреть детали',
'Follow Pack Not Found': 'Пакет подписок не найден',
'Follow pack not found': 'Пакет подписок не найден',
Users: 'Пользователи',
Feed: 'Лента',
- 'Follow Pack': 'Пакет Подписок'
+ 'Follow Pack': 'Пакет Подписок',
+ '24h Pulse': 'Пульс 24ч',
+ 'Load earlier': 'Загрузить ранее',
+ 'Last 24 hours': 'Последние 24 часа',
+ 'Last {{count}} days': 'Последние {{count}} дней',
+ notes: 'заметки'
}
}
diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts
index b0e198c1..c1ff4817 100644
--- a/src/i18n/locales/th.ts
+++ b/src/i18n/locales/th.ts
@@ -545,18 +545,26 @@ export default {
'Optimal relays': 'รีเลย์ที่เหมาะสม',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมสำเร็จ (รีเลย์เขียนของคุณและรีเลย์อ่านของผู้ใช้ที่กล่าวถึง)',
- 'Failed to republish to optimal relays: {{error}}': 'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมล้มเหลว: {{error}}',
+ 'Failed to republish to optimal relays: {{error}}':
+ 'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมล้มเหลว: {{error}}',
'External Content': 'เนื้อหาภายนอก',
Highlight: 'ไฮไลต์',
'Optimal relays and {{count}} other relays': 'รีเลย์ที่เหมาะสมและรีเลย์อื่น {{count}} รายการ',
- 'Likely spam account (Trust score: {{percentile}}%)': 'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
- 'Suspicious account (Trust score: {{percentile}}%)': 'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ 'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
+ 'Suspicious account (Trust score: {{percentile}}%)':
+ 'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
'n users': '{{count}} ผู้ใช้',
'View Details': 'ดูรายละเอียด',
'Follow Pack Not Found': 'ไม่พบแพ็คการติดตาม',
'Follow pack not found': 'ไม่พบแพ็คการติดตาม',
Users: 'ผู้ใช้',
Feed: 'ฟีด',
- 'Follow Pack': 'แพ็คการติดตาม'
+ 'Follow Pack': 'แพ็คการติดตาม',
+ '24h Pulse': '24h พัลส์',
+ 'Load earlier': 'โหลดข้อมูลก่อนหน้า',
+ 'Last 24 hours': '24 ชั่วโมงที่แล้ว',
+ 'Last {{count}} days': '{{count}} วันที่แล้ว',
+ notes: 'โน้ต'
}
}
diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts
index 4c3b844b..c5f7a8ec 100644
--- a/src/i18n/locales/zh.ts
+++ b/src/i18n/locales/zh.ts
@@ -544,7 +544,8 @@ export default {
'External Content': '外部内容',
Highlight: '高亮',
'Optimal relays and {{count}} other relays': '最佳中继器和其他 {{count}} 个中继器',
- 'Likely spam account (Trust score: {{percentile}}%)': '疑似垃圾账号(信任分数:{{percentile}}%)',
+ 'Likely spam account (Trust score: {{percentile}}%)':
+ '疑似垃圾账号(信任分数:{{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)': '可疑账号(信任分数:{{percentile}}%)',
'n users': '{{count}} 位用户',
'View Details': '查看详情',
@@ -552,6 +553,11 @@ export default {
'Follow pack not found': '未找到关注包',
Users: '用户',
Feed: '动态',
- 'Follow Pack': '关注包'
+ 'Follow Pack': '关注包',
+ '24h Pulse': '24h 动态',
+ 'Load earlier': '加载更早',
+ 'Last 24 hours': '最近 24 小时',
+ 'Last {{count}} days': '最近 {{count}} 天',
+ notes: '笔记'
}
}
diff --git a/src/lib/link.ts b/src/lib/link.ts
index 8a08d7e0..a7cbb8b7 100644
--- a/src/lib/link.ts
+++ b/src/lib/link.ts
@@ -87,3 +87,7 @@ export const toChachiChat = (relay: string, d: string) => {
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`
}
export const toNjump = (id: string) => `https://njump.me/${id}`
+export const toUserAggregationDetail = (feedId: string, pubkey: string) => {
+ const npub = nip19.npubEncode(pubkey)
+ return `/user-aggregation/${feedId}/${npub}`
+}
diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx
index 2f5b1fea..4ccb5070 100644
--- a/src/pages/secondary/NoteListPage/index.tsx
+++ b/src/pages/secondary/NoteListPage/index.tsx
@@ -16,12 +16,12 @@ import { useTranslation } from 'react-i18next'
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { push } = useSecondaryPage()
- const { relayList, pubkey } = useNostr()
+ const { pubkey } = useNostr()
const [title, setTitle] = useState(null)
const [controls, setControls] = useState(null)
const [data, setData] = useState<
| {
- type: 'hashtag' | 'search' | 'externalContent'
+ type: 'hashtag' | 'search'
kinds?: number[]
}
| {
@@ -64,18 +64,6 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
])
return
}
- const externalContentId = searchParams.get('i')
- if (externalContentId) {
- setData({ type: 'externalContent' })
- setTitle(externalContentId)
- setSubRequests([
- {
- filter: { '#I': [externalContentId], ...(kinds.length > 0 ? { kinds } : {}) },
- urls: BIG_RELAY_URLS.concat(relayList?.write || [])
- }
- ])
- return
- }
const domain = searchParams.get('d')
if (domain) {
setTitle(
@@ -119,7 +107,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
)
} else if (data) {
- content =
+ content =
}
return (
diff --git a/src/pages/secondary/UserAggregationDetailPage/index.tsx b/src/pages/secondary/UserAggregationDetailPage/index.tsx
new file mode 100644
index 00000000..b6e0b3a6
--- /dev/null
+++ b/src/pages/secondary/UserAggregationDetailPage/index.tsx
@@ -0,0 +1,86 @@
+import NoteCard from '@/components/NoteCard'
+import { SimpleUsername } from '@/components/Username'
+import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
+import userAggregationService from '@/services/user-aggregation.service'
+import { nip19, NostrEvent } from 'nostr-tools'
+import { forwardRef, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+const UserAggregationDetailPage = forwardRef(
+ (
+ {
+ feedId,
+ npub,
+ index
+ }: {
+ feedId?: string
+ npub?: string
+ index?: number
+ },
+ ref
+ ) => {
+ const { t } = useTranslation()
+ const [aggregation, setAggregation] = useState([])
+
+ const pubkey = useMemo(() => {
+ if (!npub) return undefined
+ try {
+ const { type, data } = nip19.decode(npub)
+ if (type === 'npub') return data
+ if (type === 'nprofile') return data.pubkey
+ } catch {
+ return undefined
+ }
+ }, [npub])
+
+ useEffect(() => {
+ if (!feedId || !pubkey) {
+ setAggregation([])
+ return
+ }
+
+ const updateEvents = () => {
+ const events = userAggregationService.getAggregation(feedId, pubkey)
+ setAggregation(events)
+ }
+
+ const unSub = userAggregationService.subscribeAggregationChange(feedId, pubkey, () => {
+ updateEvents()
+ })
+
+ updateEvents()
+
+ return unSub
+ }, [feedId, pubkey, setAggregation])
+
+ if (!pubkey || !feedId) {
+ return (
+
+
+ {t('Invalid user')}
+
+
+ )
+ }
+
+ return (
+ }
+ displayScrollToTopButton
+ >
+
+ {aggregation.map((event) => (
+
+ ))}
+
{t('no more notes')}
+
+
+ )
+ }
+)
+
+UserAggregationDetailPage.displayName = 'UserAggregationDetailPage'
+
+export default UserAggregationDetailPage
diff --git a/src/routes/secondary.tsx b/src/routes/secondary.tsx
index fc90bea8..9c95094e 100644
--- a/src/routes/secondary.tsx
+++ b/src/routes/secondary.tsx
@@ -21,6 +21,7 @@ import SearchPage from '@/pages/secondary/SearchPage'
import SettingsPage from '@/pages/secondary/SettingsPage'
import SystemSettingsPage from '@/pages/secondary/SystemSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage'
+import UserAggregationDetailPage from '@/pages/secondary/UserAggregationDetailPage'
import WalletPage from '@/pages/secondary/WalletPage'
import { match } from 'path-to-regexp'
import { isValidElement } from 'react'
@@ -50,7 +51,8 @@ const SECONDARY_ROUTE_CONFIGS = [
{ path: '/mutes', element: },
{ path: '/rizful', element: },
{ path: '/bookmarks', element: },
- { path: '/follow-packs/:id', element: }
+ { path: '/follow-packs/:id', element: },
+ { path: '/user-aggregation/:feedId/:npub', element: }
]
export const SECONDARY_ROUTES = SECONDARY_ROUTE_CONFIGS.map(({ path, element }) => ({
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index d27d26a3..8bcb7502 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -251,6 +251,7 @@ class ClientService extends EventTarget {
Object.entries(filter)
.sort()
.forEach(([key, value]) => {
+ if (key === 'limit') return
if (Array.isArray(value)) {
stableFilter[key] = [...value].sort()
}
@@ -298,7 +299,6 @@ class ClientService extends EventTarget {
const newEventIdSet = new Set()
const requestCount = subRequests.length
const threshold = Math.floor(requestCount / 2)
- let eventIdSet = new Set()
let events: NEvent[] = []
let eosedCount = 0
@@ -313,13 +313,7 @@ class ClientService extends EventTarget {
eosedCount++
}
- _events.forEach((evt) => {
- if (eventIdSet.has(evt.id)) return
- eventIdSet.add(evt.id)
- events.push(evt)
- })
- events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
- eventIdSet = new Set(events.map((evt) => evt.id))
+ events = this.mergeTimelines(events, _events)
if (eosedCount >= threshold) {
onEvents(events, eosedCount >= requestCount)
@@ -352,6 +346,31 @@ class ClientService extends EventTarget {
}
}
+ private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] {
+ if (a.length === 0) return [...b]
+ if (b.length === 0) return [...a]
+
+ const result: NEvent[] = []
+ let i = 0
+ let j = 0
+ while (i < a.length && j < b.length) {
+ const cmp = compareEvents(a[i], b[j])
+ if (cmp > 0) {
+ result.push(a[i])
+ i++
+ } else if (cmp < 0) {
+ result.push(b[j])
+ j++
+ } else {
+ result.push(a[i])
+ i++
+ j++
+ }
+ }
+
+ return result
+ }
+
async loadMoreTimeline(key: string, until: number, limit: number) {
const timeline = this.timelines[key]
if (!timeline) return []
@@ -552,9 +571,9 @@ class ClientService extends EventTarget {
let cachedEvents: NEvent[] = []
let since: number | undefined
if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) {
- cachedEvents = (
- await this.eventDataLoader.loadMany(timeline.refs.slice(0, filter.limit).map(([id]) => id))
- ).filter((evt) => !!evt && !(evt instanceof Error)) as NEvent[]
+ cachedEvents = (await this.eventDataLoader.loadMany(timeline.refs.map(([id]) => id))).filter(
+ (evt) => !!evt && !(evt instanceof Error)
+ ) as NEvent[]
if (cachedEvents.length) {
onEvents([...cachedEvents], false)
since = cachedEvents[0].created_at + 1
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 57a1b4a7..77019415 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -57,6 +57,7 @@ class LocalStorageService {
private enableSingleColumnLayout: boolean = true
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
private filterOutOnionRelays: boolean = !isTorBrowser()
+ private pinnedPubkeys: Set = new Set()
constructor() {
if (!LocalStorageService.instance) {
@@ -75,7 +76,7 @@ class LocalStorageService {
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
this.noteListMode =
- noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
+ noteListModeStr && ['posts', 'postsAndReplies', '24h'].includes(noteListModeStr)
? (noteListModeStr as TNoteListMode)
: 'posts'
const lastReadNotificationTimeMapStr =
@@ -230,6 +231,11 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false'
}
+ const pinnedPubkeysStr = window.localStorage.getItem(StorageKey.PINNED_PUBKEYS)
+ if (pinnedPubkeysStr) {
+ this.pinnedPubkeys = new Set(JSON.parse(pinnedPubkeysStr))
+ }
+
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -558,6 +564,18 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOut
window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
}
+
+ getPinnedPubkeys(): Set {
+ return this.pinnedPubkeys
+ }
+
+ setPinnedPubkeys(pinnedPubkeys: Set) {
+ this.pinnedPubkeys = pinnedPubkeys
+ window.localStorage.setItem(
+ StorageKey.PINNED_PUBKEYS,
+ JSON.stringify(Array.from(this.pinnedPubkeys))
+ )
+ }
}
const instance = new LocalStorageService()
diff --git a/src/services/user-aggregation.service.ts b/src/services/user-aggregation.service.ts
new file mode 100644
index 00000000..2d5ff77e
--- /dev/null
+++ b/src/services/user-aggregation.service.ts
@@ -0,0 +1,207 @@
+import { getEventKey } from '@/lib/event'
+import storage from '@/services/local-storage.service'
+import { TFeedSubRequest } from '@/types'
+import dayjs from 'dayjs'
+import { Event } from 'nostr-tools'
+
+export type TUserAggregation = {
+ pubkey: string
+ events: Event[]
+ count: number
+ lastEventTime: number
+}
+
+class UserAggregationService {
+ static instance: UserAggregationService
+
+ private pinnedPubkeys: Set = new Set()
+ private aggregationStore: Map> = new Map()
+ private listenersMap: Map void>> = new Map()
+ private lastViewedMap: Map = new Map()
+
+ constructor() {
+ if (UserAggregationService.instance) {
+ return UserAggregationService.instance
+ }
+ UserAggregationService.instance = this
+ this.pinnedPubkeys = storage.getPinnedPubkeys()
+ }
+
+ subscribeAggregationChange(feedId: string, pubkey: string, listener: () => void) {
+ return this.subscribe(`aggregation:${feedId}:${pubkey}`, listener)
+ }
+
+ private notifyAggregationChange(feedId: string, pubkey: string) {
+ this.notify(`aggregation:${feedId}:${pubkey}`)
+ }
+
+ subscribeViewedTimeChange(feedId: string, pubkey: string, listener: () => void) {
+ return this.subscribe(`viewedTime:${feedId}:${pubkey}`, listener)
+ }
+
+ private notifyViewedTimeChange(feedId: string, pubkey: string) {
+ this.notify(`viewedTime:${feedId}:${pubkey}`)
+ }
+
+ private subscribe(type: string, listener: () => void) {
+ if (!this.listenersMap.has(type)) {
+ this.listenersMap.set(type, new Set())
+ }
+ this.listenersMap.get(type)!.add(listener)
+
+ return () => {
+ this.listenersMap.get(type)?.delete(listener)
+ if (this.listenersMap.get(type)?.size === 0) {
+ this.listenersMap.delete(type)
+ }
+ }
+ }
+
+ private notify(type: string) {
+ const listeners = this.listenersMap.get(type)
+ if (listeners) {
+ listeners.forEach((listener) => listener())
+ }
+ }
+
+ // Pinned users management
+ getPinnedPubkeys(): string[] {
+ return [...this.pinnedPubkeys]
+ }
+
+ isPinned(pubkey: string): boolean {
+ return this.pinnedPubkeys.has(pubkey)
+ }
+
+ pinUser(pubkey: string) {
+ this.pinnedPubkeys.add(pubkey)
+ storage.setPinnedPubkeys(this.pinnedPubkeys)
+ }
+
+ unpinUser(pubkey: string) {
+ this.pinnedPubkeys.delete(pubkey)
+ storage.setPinnedPubkeys(this.pinnedPubkeys)
+ }
+
+ togglePin(pubkey: string) {
+ if (this.isPinned(pubkey)) {
+ this.unpinUser(pubkey)
+ } else {
+ this.pinUser(pubkey)
+ }
+ }
+
+ // Aggregate events by user
+ aggregateByUser(events: Event[]): TUserAggregation[] {
+ const userEventsMap = new Map()
+ const processedKeys = new Set()
+
+ events.forEach((event) => {
+ const key = getEventKey(event)
+ if (processedKeys.has(key)) return
+ processedKeys.add(key)
+
+ const existing = userEventsMap.get(event.pubkey) || []
+ existing.push(event)
+ userEventsMap.set(event.pubkey, existing)
+ })
+
+ const aggregations: TUserAggregation[] = []
+ userEventsMap.forEach((events, pubkey) => {
+ if (events.length === 0) {
+ return
+ }
+
+ aggregations.push({
+ pubkey,
+ events: events,
+ count: events.length,
+ lastEventTime: events[0].created_at
+ })
+ })
+
+ return aggregations.sort((a, b) => {
+ return b.lastEventTime - a.lastEventTime
+ })
+ }
+
+ sortWithPinned(aggregations: TUserAggregation[]): TUserAggregation[] {
+ const pinned: TUserAggregation[] = []
+ const unpinned: TUserAggregation[] = []
+
+ aggregations.forEach((agg) => {
+ if (this.isPinned(agg.pubkey)) {
+ pinned.push(agg)
+ } else {
+ unpinned.push(agg)
+ }
+ })
+
+ return [...pinned, ...unpinned]
+ }
+
+ saveAggregations(feedId: string, aggregations: TUserAggregation[]) {
+ const map = new Map()
+ aggregations.forEach((agg) => map.set(agg.pubkey, agg.events))
+ this.aggregationStore.set(feedId, map)
+ aggregations.forEach((agg) => {
+ this.notifyAggregationChange(feedId, agg.pubkey)
+ })
+ }
+
+ getAggregation(feedId: string, pubkey: string): Event[] {
+ return this.aggregationStore.get(feedId)?.get(pubkey) || []
+ }
+
+ clearAggregations(feedId: string) {
+ this.aggregationStore.delete(feedId)
+ }
+
+ getFeedId(subRequests: TFeedSubRequest[], showKinds: number[] = []): string {
+ const requestStr = subRequests
+ .map((req) => {
+ const urls = req.urls.sort().join(',')
+ const filter = Object.entries(req.filter)
+ .filter(([key]) => !['since', 'until', 'limit'].includes(key))
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, value]) => `${key}:${JSON.stringify(value)}`)
+ .join('|')
+ return `${urls}#${filter}`
+ })
+ .join(';;')
+
+ const kindsStr = showKinds.sort((a, b) => a - b).join(',')
+ const input = `${requestStr}::${kindsStr}`
+
+ let hash = 0
+ for (let i = 0; i < input.length; i++) {
+ const char = input.charCodeAt(i)
+ hash = (hash << 5) - hash + char
+ hash = hash & hash
+ }
+
+ return Math.abs(hash).toString(36)
+ }
+
+ markAsViewed(feedId: string, pubkey: string) {
+ const key = `${feedId}:${pubkey}`
+ this.lastViewedMap.set(key, dayjs().unix())
+ this.notifyViewedTimeChange(feedId, pubkey)
+ }
+
+ markAsUnviewed(feedId: string, pubkey: string) {
+ const key = `${feedId}:${pubkey}`
+ this.lastViewedMap.delete(key)
+ this.notifyViewedTimeChange(feedId, pubkey)
+ }
+
+ getLastViewedTime(feedId: string, pubkey: string): number {
+ const key = `${feedId}:${pubkey}`
+ const lastViewed = this.lastViewedMap.get(key)
+
+ return lastViewed ?? 0
+ }
+}
+
+const userAggregationService = new UserAggregationService()
+export default userAggregationService
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 418a9fc9..9c9ae906 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -125,7 +125,7 @@ export type TPublishOptions = {
minPow?: number
}
-export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you'
+export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | '24h'
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'