feat: 24h pulse

This commit is contained in:
codytseng
2025-11-29 00:34:53 +08:00
parent b21855c294
commit ce7afeb250
31 changed files with 1086 additions and 123 deletions

View File

@@ -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({
}}
>
<ListFilter size={16} />
{t('Filter')}
{isDifferentFromSaved && (
<div className="absolute size-2 rounded-full bg-primary left-7 top-2 ring-2 ring-background" />
)}

View File

@@ -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<TNoteListMode>(() => storage.getNoteListMode())
const supportTouch = useMemo(() => isTouchDevice(), [])
const noteListRef = useRef<TNoteListRef>(null)
const userAggregationListRef = useRef<TUserAggregationListRef>(null)
const topRef = useRef<HTMLDivElement>(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,21 +54,46 @@ export default function NormalFeed({
return (
<>
<Tabs
value={listMode}
value={listMode === '24h' && disable24hMode ? 'posts' : listMode}
tabs={[
{ value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' }
{ value: 'postsAndReplies', label: 'Replies' },
...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : [])
]}
onTabChange={(listMode) => {
handleListModeChange(listMode as TNoteListMode)
}}
options={
<>
{!supportTouch && <RefreshButton onClick={() => noteListRef.current?.refresh()} />}
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
{!supportTouch && (
<RefreshButton
onClick={() => {
if (listMode === '24h') {
userAggregationListRef.current?.refresh()
} else {
noteListRef.current?.refresh()
}
}}
/>
)}
{showKindsFilter && (
<KindFilter
showKinds={temporaryShowKinds}
onShowKindsChange={handleShowKindsChange}
/>
)}
</>
}
/>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{listMode === '24h' && !disable24hMode ? (
<UserAggregationList
ref={userAggregationListRef}
showKinds={temporaryShowKinds}
subRequests={subRequests}
filterFn={filterFn}
/>
) : (
<NoteList
ref={noteListRef}
showKinds={temporaryShowKinds}
@@ -67,6 +103,7 @@ export default function NormalFeed({
areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason}
/>
)}
</>
)
}

View File

@@ -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
}

View File

@@ -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<TNoteListMode>(() => storage.getNoteListMode())
const [listMode, setListMode] = useState<TNoteListMode>(() => {
const mode = storage.getNoteListMode()
if (mode === '24h') {
return 'posts'
}
return mode
})
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [pinnedEventIds, setPinnedEventIds] = useState<string[]>([])
const tabs = useMemo(() => {

View File

@@ -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<Event[]>([])
const [timelineKey, setTimelineKey] = useState<string | undefined>(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<Set<string>>(
new Set(userAggregationService.getPinnedPubkeys())
)
const feedId = useMemo(() => {
return userAggregationService.getFeedId(subRequests, showKinds)
}, [JSON.stringify(subRequests), JSON.stringify(showKinds)])
const bottomRef = useRef<HTMLDivElement | null>(null)
const topRef = useRef<HTMLDivElement | null>(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 = (
<div className="min-h-screen">
{displayedAggregations.map((agg) => (
<UserAggregationItem
key={agg.pubkey}
feedId={feedId}
aggregation={agg}
onClick={() => handleViewUser(agg)}
/>
))}
{loading || hasMore ? (
<div ref={bottomRef}>
<UserAggregationItemSkeleton />
</div>
) : displayedAggregations.length === 0 ? (
<div className="flex justify-center w-full mt-2">
<Button size="lg" onClick={() => setRefreshCount((count) => count + 1)}>
{t('Reload')}
</Button>
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
)}
</div>
)
return (
<div>
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
{showLoadingBar && <LoadingBar />}
<div className="border-b h-12 pl-4 pr-1 flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground flex items-center gap-1.5 min-w-0">
<span className="font-medium text-foreground">
{lastXDays === 1 ? t('Last 24 hours') : t('Last {{count}} days', { count: lastXDays })}
</span>
·
<span>
{filteredEvents.length} {t('notes')}
</span>
</div>
<Button
variant="ghost"
className="h-10 px-3 shrink-0 rounded-lg text-muted-foreground hover:text-foreground"
disabled={showLoadingBar}
onClick={handleLoadEarlier}
>
{showLoadingBar ? <Loader className="animate-spin" /> : <History />}
{t('Load earlier')}
</Button>
</div>
{supportTouch ? (
<PullToRefresh
onRefresh={async () => {
refresh()
await new Promise((resolve) => setTimeout(resolve, 1000))
}}
pullingContent=""
>
{list}
</PullToRefresh>
) : (
list
)}
</div>
)
})
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 (
<div
className="group relative flex items-center gap-4 px-4 py-3 border-b hover:bg-accent/30 cursor-pointer transition-all duration-200"
onClick={onClick}
>
<UserAvatar userId={aggregation.pubkey} />
<div className="flex-1 min-w-0 flex flex-col">
<Username
userId={aggregation.pubkey}
className="font-semibold text-base truncate max-w-fit"
skeletonClassName="h-4"
/>
<FormattedTimestamp
timestamp={aggregation.lastEventTime}
className="text-sm text-muted-foreground"
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={onTogglePin}
className={`flex-shrink-0 ${
isPinned
? 'text-primary hover:text-primary/80'
: 'text-muted-foreground hover:text-foreground'
}`}
title={isPinned ? t('Unpin') : t('Pin')}
>
{isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</Button>
<button
className={cn(
'flex-shrink-0 size-10 rounded-full font-bold tabular-nums text-primary border border-primary/80 bg-primary/10 hover:border-primary hover:bg-primary/20 flex flex-col items-center justify-center transition-colors',
!hasNewEvents &&
'border-muted-foreground/80 text-muted-foreground/80 bg-muted-foreground/10 hover:border-muted-foreground hover:text-muted-foreground hover:bg-muted-foreground/20'
)}
onClick={onToggleViewed}
>
{aggregation.count > 99 ? '99+' : aggregation.count}
</button>
</div>
)
}
function UserAggregationItemSkeleton() {
return (
<div className="flex items-center gap-4 px-4 py-3">
<Skeleton className="size-10 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-36 my-1" />
<Skeleton className="h-3 w-14 my-1" />
</div>
<Skeleton className="size-10 rounded-full" />
</div>
)
}

View File

@@ -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

View File

@@ -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: 'ملاحظات'
}
}

View File

@@ -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'
}
}

View File

@@ -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'
}
}

View File

@@ -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'
}
}

View File

@@ -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: 'یادداشت‌ها'
}
}

View File

@@ -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'
}
}

View File

@@ -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: 'नोट्स'
}
}

View File

@@ -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'
}
}

View File

@@ -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'
}
}

View File

@@ -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: 'ノート'
}
}

View File

@@ -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: '노트'
}
}

View File

@@ -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'
}
}

View File

@@ -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'
}
}

View File

@@ -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'
}
}

View File

@@ -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: 'заметки'
}
}

View File

@@ -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: 'โน้ต'
}
}

View File

@@ -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: '笔记'
}
}

View File

@@ -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}`
}

View File

@@ -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<React.ReactNode>(null)
const [controls, setControls] = useState<React.ReactNode>(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) => {
</div>
)
} else if (data) {
content = <NormalFeed subRequests={subRequests} />
content = <NormalFeed subRequests={subRequests} disable24hMode={data.type !== 'domain'} />
}
return (

View File

@@ -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<NostrEvent[]>([])
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 (
<SecondaryPageLayout ref={ref} index={index} title={t('User Posts')}>
<div className="flex justify-center items-center h-40 text-muted-foreground">
{t('Invalid user')}
</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={<SimpleUsername userId={pubkey} className="truncate" />}
displayScrollToTopButton
>
<div className="min-h-screen">
{aggregation.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
</div>
</SecondaryPageLayout>
)
}
)
UserAggregationDetailPage.displayName = 'UserAggregationDetailPage'
export default UserAggregationDetailPage

View File

@@ -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: <MuteListPage /> },
{ path: '/rizful', element: <RizfulPage /> },
{ path: '/bookmarks', element: <BookmarkPage /> },
{ path: '/follow-packs/:id', element: <FollowPackPage /> }
{ path: '/follow-packs/:id', element: <FollowPackPage /> },
{ path: '/user-aggregation/:feedId/:npub', element: <UserAggregationDetailPage /> }
]
export const SECONDARY_ROUTES = SECONDARY_ROUTE_CONFIGS.map(({ path, element }) => ({

View File

@@ -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<string>()
const requestCount = subRequests.length
const threshold = Math.floor(requestCount / 2)
let eventIdSet = new Set<string>()
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

View File

@@ -57,6 +57,7 @@ class LocalStorageService {
private enableSingleColumnLayout: boolean = true
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
private filterOutOnionRelays: boolean = !isTorBrowser()
private pinnedPubkeys: Set<string> = 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<string> {
return this.pinnedPubkeys
}
setPinnedPubkeys(pinnedPubkeys: Set<string>) {
this.pinnedPubkeys = pinnedPubkeys
window.localStorage.setItem(
StorageKey.PINNED_PUBKEYS,
JSON.stringify(Array.from(this.pinnedPubkeys))
)
}
}
const instance = new LocalStorageService()

View File

@@ -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<string> = new Set()
private aggregationStore: Map<string, Map<string, Event[]>> = new Map()
private listenersMap: Map<string, Set<() => void>> = new Map()
private lastViewedMap: Map<string, number> = 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<string, Event[]>()
const processedKeys = new Set<string>()
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<string, Event[]>()
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

View File

@@ -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'