From ce7afeb250184be91417c43cbcd11cf741c138bc Mon Sep 17 00:00:00 2001 From: codytseng Date: Sat, 29 Nov 2025 00:34:53 +0800 Subject: [PATCH] feat: 24h pulse --- src/components/KindFilter/index.tsx | 3 +- src/components/NormalFeed/index.tsx | 67 ++- src/components/NoteList/index.tsx | 37 +- src/components/Profile/ProfileFeed.tsx | 8 +- src/components/UserAggregationList/index.tsx | 466 ++++++++++++++++++ src/constants.ts | 1 + src/i18n/locales/ar.ts | 13 +- src/i18n/locales/de.ts | 13 +- src/i18n/locales/en.ts | 7 +- src/i18n/locales/es.ts | 16 +- src/i18n/locales/fa.ts | 16 +- src/i18n/locales/fr.ts | 16 +- src/i18n/locales/hi.ts | 16 +- src/i18n/locales/hu.ts | 13 +- src/i18n/locales/it.ts | 16 +- src/i18n/locales/ja.ts | 16 +- src/i18n/locales/ko.ts | 13 +- src/i18n/locales/pl.ts | 16 +- src/i18n/locales/pt-BR.ts | 16 +- src/i18n/locales/pt-PT.ts | 16 +- src/i18n/locales/ru.ts | 16 +- src/i18n/locales/th.ts | 16 +- src/i18n/locales/zh.ts | 10 +- src/lib/link.ts | 4 + src/pages/secondary/NoteListPage/index.tsx | 18 +- .../UserAggregationDetailPage/index.tsx | 86 ++++ src/routes/secondary.tsx | 4 +- src/services/client.service.ts | 41 +- src/services/local-storage.service.ts | 20 +- src/services/user-aggregation.service.ts | 207 ++++++++ src/types/index.d.ts | 2 +- 31 files changed, 1086 insertions(+), 123 deletions(-) create mode 100644 src/components/UserAggregationList/index.tsx create mode 100644 src/pages/secondary/UserAggregationDetailPage/index.tsx create mode 100644 src/services/user-aggregation.service.ts 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'