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" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
className={cn( className={cn(
'relative w-fit px-3 focus:text-foreground', 'relative w-fit px-3 hover:text-foreground',
!isDifferentFromSaved && 'text-muted-foreground' !isDifferentFromSaved && 'text-muted-foreground'
)} )}
onClick={() => { onClick={() => {
@@ -94,7 +94,6 @@ export default function KindFilter({
}} }}
> >
<ListFilter size={16} /> <ListFilter size={16} />
{t('Filter')}
{isDifferentFromSaved && ( {isDifferentFromSaved && (
<div className="absolute size-2 rounded-full bg-primary left-7 top-2 ring-2 ring-background" /> <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 NoteList, { TNoteListRef } from '@/components/NoteList'
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
@@ -13,12 +15,16 @@ export default function NormalFeed({
subRequests, subRequests,
areAlgoRelays = false, areAlgoRelays = false,
isMainFeed = false, isMainFeed = false,
showRelayCloseReason = false showRelayCloseReason = false,
filterFn,
disable24hMode = false
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean areAlgoRelays?: boolean
isMainFeed?: boolean isMainFeed?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
filterFn?: (event: Event) => boolean
disable24hMode?: boolean
}) { }) {
const { hideUntrustedNotes } = useUserTrust() const { hideUntrustedNotes } = useUserTrust()
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
@@ -26,13 +32,18 @@ export default function NormalFeed({
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode()) const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const noteListRef = useRef<TNoteListRef>(null) 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) => { const handleListModeChange = (mode: TNoteListMode) => {
setListMode(mode) setListMode(mode)
if (isMainFeed) { if (isMainFeed) {
storage.setNoteListMode(mode) storage.setNoteListMode(mode)
} }
noteListRef.current?.scrollToTop('smooth') topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }
const handleShowKindsChange = (newShowKinds: number[]) => { const handleShowKindsChange = (newShowKinds: number[]) => {
@@ -43,30 +54,56 @@ export default function NormalFeed({
return ( return (
<> <>
<Tabs <Tabs
value={listMode} value={listMode === '24h' && disable24hMode ? 'posts' : listMode}
tabs={[ tabs={[
{ value: 'posts', label: 'Notes' }, { value: 'posts', label: 'Notes' },
{ value: 'postsAndReplies', label: 'Replies' } { value: 'postsAndReplies', label: 'Replies' },
...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : [])
]} ]}
onTabChange={(listMode) => { onTabChange={(listMode) => {
handleListModeChange(listMode as TNoteListMode) handleListModeChange(listMode as TNoteListMode)
}} }}
options={ options={
<> <>
{!supportTouch && <RefreshButton onClick={() => noteListRef.current?.refresh()} />} {!supportTouch && (
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} /> <RefreshButton
onClick={() => {
if (listMode === '24h') {
userAggregationListRef.current?.refresh()
} else {
noteListRef.current?.refresh()
}
}}
/>
)}
{showKindsFilter && (
<KindFilter
showKinds={temporaryShowKinds}
onShowKindsChange={handleShowKindsChange}
/>
)}
</> </>
} }
/> />
<NoteList <div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
ref={noteListRef} {listMode === '24h' && !disable24hMode ? (
showKinds={temporaryShowKinds} <UserAggregationList
subRequests={subRequests} ref={userAggregationListRef}
hideReplies={listMode === 'posts'} showKinds={temporaryShowKinds}
hideUntrustedNotes={hideUntrustedNotes} subRequests={subRequests}
areAlgoRelays={areAlgoRelays} filterFn={filterFn}
showRelayCloseReason={showRelayCloseReason} />
/> ) : (
<NoteList
ref={noteListRef}
showKinds={temporaryShowKinds}
subRequests={subRequests}
hideReplies={listMode === 'posts'}
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason}
/>
)}
</> </>
) )
} }

View File

@@ -33,7 +33,26 @@ const LIMIT = 200
const ALGO_LIMIT = 500 const ALGO_LIMIT = 500
const SHOW_COUNT = 10 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, subRequests,
@@ -46,17 +65,6 @@ const NoteList = forwardRef(
pinnedEventIds, pinnedEventIds,
filterFn, filterFn,
showNewNotesDirectly = false 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 ref
) => { ) => {
@@ -415,8 +423,3 @@ const NoteList = forwardRef(
) )
NoteList.displayName = 'NoteList' NoteList.displayName = 'NoteList'
export default 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 { pubkey: myPubkey, pinListEvent: myPinListEvent } = useNostr()
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) 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 [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const [pinnedEventIds, setPinnedEventIds] = useState<string[]>([]) const [pinnedEventIds, setPinnedEventIds] = useState<string[]>([])
const tabs = useMemo(() => { 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', ENABLE_SINGLE_COLUMN_LAYOUT: 'enableSingleColumnLayout',
FAVICON_URL_TEMPLATE: 'faviconUrlTemplate', FAVICON_URL_TEMPLATE: 'faviconUrlTemplate',
FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays', FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays',
PINNED_PUBKEYS: 'pinnedPubkeys',
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated

View File

@@ -547,11 +547,13 @@ export default {
'Optimal relays': 'المرحلات المثلى', 'Optimal relays': 'المرحلات المثلى',
"Successfully republish to optimal relays (your write relays and mentioned users' read 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': 'محتوى خارجي', 'External Content': 'محتوى خارجي',
Highlight: 'تسليط الضوء', Highlight: 'تسليط الضوء',
'Optimal relays and {{count}} other relays': 'المرحلات المثلى و {{count}} مرحلات أخرى', '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}}%)', 'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشبوه (درجة الثقة: {{percentile}}%)',
'n users': '{{count}} مستخدمين', 'n users': '{{count}} مستخدمين',
'View Details': 'عرض التفاصيل', 'View Details': 'عرض التفاصيل',
@@ -559,6 +561,11 @@ export default {
'Follow pack not found': 'لم يتم العثور على حزمة المتابعة', 'Follow pack not found': 'لم يتم العثور على حزمة المتابعة',
Users: 'المستخدمون', Users: 'المستخدمون',
Feed: 'التغذية', 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', 'External Content': 'Externer Inhalt',
Highlight: 'Hervorheben', Highlight: 'Hervorheben',
'Optimal relays and {{count}} other relays': 'Optimale Relays und {{count}} andere Relays', 'Optimal relays and {{count}} other relays': 'Optimale Relays und {{count}} andere Relays',
'Likely spam account (Trust score: {{percentile}}%)': 'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)', 'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)',
'n users': '{{count}} Benutzer', 'n users': '{{count}} Benutzer',
'View Details': 'Details anzeigen', 'View Details': 'Details anzeigen',
'Follow Pack Not Found': 'Follow-Pack nicht gefunden', 'Follow Pack Not Found': 'Follow-Pack nicht gefunden',
'Follow pack not found': 'Follow-Pack nicht gefunden', 'Follow pack not found': 'Follow-Pack nicht gefunden',
Users: 'Benutzer', Users: 'Benutzer',
Feed: 'Feed', 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', 'Follow pack not found': 'Follow pack not found',
Users: 'Users', Users: 'Users',
Feed: 'Feed', 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', 'Optimal relays': 'Relays óptimos',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)": "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)', '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', 'External Content': 'Contenido externo',
Highlight: 'Destacado', Highlight: 'Destacado',
'Optimal relays and {{count}} other relays': 'Relays óptimos y {{count}} otros relays', '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}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Cuenta sospechosa (Puntuación de confianza: {{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', 'n users': '{{count}} usuarios',
'View Details': 'Ver detalles', 'View Details': 'Ver detalles',
'Follow Pack Not Found': 'Paquete de seguimiento no encontrado', 'Follow Pack Not Found': 'Paquete de seguimiento no encontrado',
'Follow pack not found': 'Paquete de seguimiento no encontrado', 'Follow pack not found': 'Paquete de seguimiento no encontrado',
Users: 'Usuarios', Users: 'Usuarios',
Feed: 'Feed', 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': 'رله‌های بهینه', 'Optimal relays': 'رله‌های بهینه',
"Successfully republish to optimal relays (your write relays and mentioned users' read 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': 'محتوای خارجی', 'External Content': 'محتوای خارجی',
Highlight: 'برجسته‌سازی', Highlight: 'برجسته‌سازی',
'Optimal relays and {{count}} other relays': 'رله‌های بهینه و {{count}} رله دیگر', 'Optimal relays and {{count}} other relays': 'رله‌های بهینه و {{count}} رله دیگر',
'Likely spam account (Trust score: {{percentile}}%)': 'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)', 'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)',
'n users': '{{count}} کاربر', 'n users': '{{count}} کاربر',
'View Details': 'مشاهده جزئیات', 'View Details': 'مشاهده جزئیات',
'Follow Pack Not Found': 'بسته دنبال‌کننده یافت نشد', 'Follow Pack Not Found': 'بسته دنبال‌کننده یافت نشد',
'Follow pack not found': 'بسته دنبال‌کننده یافت نشد', 'Follow pack not found': 'بسته دنبال‌کننده یافت نشد',
Users: 'کاربران', Users: 'کاربران',
Feed: 'فید', 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', 'Optimal relays': 'Relais optimaux',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)": "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)", "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', 'External Content': 'Contenu externe',
Highlight: 'Surligner', Highlight: 'Surligner',
'Optimal relays and {{count}} other relays': 'Relais optimaux et {{count}} autres relais', '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}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Compte suspect (Score de confiance: {{percentile}}%)', 'Compte probablement spam (Score de confiance: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'Compte suspect (Score de confiance: {{percentile}}%)',
'n users': '{{count}} utilisateurs', 'n users': '{{count}} utilisateurs',
'View Details': 'Voir les détails', 'View Details': 'Voir les détails',
'Follow Pack Not Found': 'Pack de suivi introuvable', 'Follow Pack Not Found': 'Pack de suivi introuvable',
'Follow pack not found': 'Pack de suivi introuvable', 'Follow pack not found': 'Pack de suivi introuvable',
Users: 'Utilisateurs', Users: 'Utilisateurs',
Feed: 'Flux', 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': 'इष्टतम रिले', 'Optimal relays': 'इष्टतम रिले',
"Successfully republish to optimal relays (your write relays and mentioned users' read 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': 'बाहरी सामग्री', 'External Content': 'बाहरी सामग्री',
Highlight: 'हाइलाइट', Highlight: 'हाइलाइट',
'Optimal relays and {{count}} other relays': 'इष्टतम रिले और {{count}} अन्य रिले', 'Optimal relays and {{count}} other relays': 'इष्टतम रिले और {{count}} अन्य रिले',
'Likely spam account (Trust score: {{percentile}}%)': 'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)', 'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)',
'n users': '{{count}} उपयोगकर्ता', 'n users': '{{count}} उपयोगकर्ता',
'View Details': 'विवरण देखें', 'View Details': 'विवरण देखें',
'Follow Pack Not Found': 'फॉलो पैक नहीं मिला', 'Follow Pack Not Found': 'फॉलो पैक नहीं मिला',
'Follow pack not found': 'फॉलो पैक नहीं मिला', 'Follow pack not found': 'फॉलो पैक नहीं मिला',
Users: 'उपयोगकर्ता', Users: 'उपयोगकर्ता',
Feed: 'फ़ीड', 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', 'External Content': 'Külső tartalom',
Highlight: 'Kiemelés', Highlight: 'Kiemelés',
'Optimal relays and {{count}} other relays': 'Optimális relay-ek és {{count}} másik relay', '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}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Gyanús fiók (Megbízhatósági pontszám: {{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ó', 'n users': '{{count}} felhasználó',
'View Details': 'Részletek megtekintése', '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ó',
'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', Users: 'Felhasználók',
Feed: 'Hírfolyam', 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', 'Optimal relays': 'Relay ottimali',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)": "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)', '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', 'External Content': 'Contenuto esterno',
Highlight: 'Evidenzia', Highlight: 'Evidenzia',
'Optimal relays and {{count}} other relays': 'Relay ottimali e {{count}} altri relay', '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}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Account sospetto (Punteggio di fiducia: {{percentile}}%)', 'Probabile account spam (Punteggio di fiducia: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'Account sospetto (Punteggio di fiducia: {{percentile}}%)',
'n users': '{{count}} utenti', 'n users': '{{count}} utenti',
'View Details': 'Visualizza dettagli', 'View Details': 'Visualizza dettagli',
'Follow Pack Not Found': 'Pacchetto di follow non trovato', 'Follow Pack Not Found': 'Pacchetto di follow non trovato',
'Follow pack not found': 'Pacchetto di follow non trovato', 'Follow pack not found': 'Pacchetto di follow non trovato',
Users: 'Utenti', Users: 'Utenti',
Feed: 'Feed', 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': '最適なリレー', 'Optimal relays': '最適なリレー',
"Successfully republish to optimal relays (your write relays and mentioned users' read 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': '外部コンテンツ', 'External Content': '外部コンテンツ',
Highlight: 'ハイライト', Highlight: 'ハイライト',
'Optimal relays and {{count}} other relays': '最適なリレーと他の{{count}}個のリレー', 'Optimal relays and {{count}} other relays': '最適なリレーと他の{{count}}個のリレー',
'Likely spam account (Trust score: {{percentile}}%)': 'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': '疑わしいアカウント(信頼スコア:{{percentile}}%', 'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%',
'Suspicious account (Trust score: {{percentile}}%)':
'疑わしいアカウント(信頼スコア:{{percentile}}%',
'n users': '{{count}}人のユーザー', 'n users': '{{count}}人のユーザー',
'View Details': '詳細を表示', 'View Details': '詳細を表示',
'Follow Pack Not Found': 'フォローパックが見つかりません', 'Follow Pack Not Found': 'フォローパックが見つかりません',
'Follow pack not found': 'フォローパックが見つかりません', 'Follow pack not found': 'フォローパックが見つかりません',
Users: 'ユーザー', Users: 'ユーザー',
Feed: 'フィード', 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': '외부 콘텐츠', 'External Content': '외부 콘텐츠',
Highlight: '하이라이트', Highlight: '하이라이트',
'Optimal relays and {{count}} other relays': '최적 릴레이 및 기타 {{count}}개 릴레이', 'Optimal relays and {{count}} other relays': '최적 릴레이 및 기타 {{count}}개 릴레이',
'Likely spam account (Trust score: {{percentile}}%)': '스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': '의심스러운 계정 (신뢰 점수: {{percentile}}%)', '스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'의심스러운 계정 (신뢰 점수: {{percentile}}%)',
'n users': '{{count}}명의 사용자', 'n users': '{{count}}명의 사용자',
'View Details': '세부 정보 보기', 'View Details': '세부 정보 보기',
'Follow Pack Not Found': '팔로우 팩을 찾을 수 없음', 'Follow Pack Not Found': '팔로우 팩을 찾을 수 없음',
'Follow pack not found': '팔로우 팩을 찾을 수 없습니다', 'Follow pack not found': '팔로우 팩을 찾을 수 없습니다',
Users: '사용자', Users: '사용자',
Feed: '피드', 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}}', 'Nie udało się opublikować ponownie na optymalnych przekaźnikach: {{error}}',
'External Content': 'Treść zewnętrzna', 'External Content': 'Treść zewnętrzna',
Highlight: 'Podświetl', Highlight: 'Podświetl',
'Optimal relays and {{count}} other relays': 'Optymalne przekaźniki i {{count}} innych przekaźników', 'Optimal relays and {{count}} other relays':
'Likely spam account (Trust score: {{percentile}}%)': 'Prawdopodobnie konto spamowe (Wynik zaufania: {{percentile}}%)', 'Optymalne przekaźniki i {{count}} innych przekaźników',
'Suspicious account (Trust score: {{percentile}}%)': 'Podejrzane konto (Wynik zaufania: {{percentile}}%)', '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', 'n users': '{{count}} użytkowników',
'View Details': 'Zobacz szczegóły', 'View Details': 'Zobacz szczegóły',
'Follow Pack Not Found': 'Nie znaleziono pakietu obserwowanych', 'Follow Pack Not Found': 'Nie znaleziono pakietu obserwowanych',
'Follow pack not found': 'Nie znaleziono pakietu obserwowanych', 'Follow pack not found': 'Nie znaleziono pakietu obserwowanych',
Users: 'Użytkownicy', Users: 'Użytkownicy',
Feed: 'Kanał', 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', 'Optimal relays': 'Relays ideais',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)": "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)', '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', 'External Content': 'Conteúdo externo',
Highlight: 'Marcação', Highlight: 'Marcação',
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays', '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}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{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', 'n users': '{{count}} usuários',
'View Details': 'Ver detalhes', 'View Details': 'Ver detalhes',
'Follow Pack Not Found': 'Pacote de seguir não encontrado', 'Follow Pack Not Found': 'Pacote de seguir não encontrado',
'Follow pack not found': 'Pacote de seguir não encontrado', 'Follow pack not found': 'Pacote de seguir não encontrado',
Users: 'Usuários', Users: 'Usuários',
Feed: 'Feed', 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', 'Optimal relays': 'Relays ideais',
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)": "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)', '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', 'External Content': 'Conteúdo externo',
Highlight: 'Destacar', Highlight: 'Destacar',
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays', '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}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{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', 'n users': '{{count}} utilizadores',
'View Details': 'Ver detalhes', 'View Details': 'Ver detalhes',
'Follow Pack Not Found': 'Pacote de seguir não encontrado', 'Follow Pack Not Found': 'Pacote de seguir não encontrado',
'Follow pack not found': 'Pacote de seguir não encontrado', 'Follow pack not found': 'Pacote de seguir não encontrado',
Users: 'Utilizadores', Users: 'Utilizadores',
Feed: 'Feed', 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': 'Оптимальные релеи', 'Optimal relays': 'Оптимальные релеи',
"Successfully republish to optimal relays (your write relays and mentioned users' read 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': 'Внешний контент', 'External Content': 'Внешний контент',
Highlight: 'Выделить', Highlight: 'Выделить',
'Optimal relays and {{count}} other relays': 'Оптимальные релеи и {{count}} других релеев', 'Optimal relays and {{count}} other relays': 'Оптимальные релеи и {{count}} других релеев',
'Likely spam account (Trust score: {{percentile}}%)': 'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)', 'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)',
'n users': '{{count}} пользователей', 'n users': '{{count}} пользователей',
'View Details': 'Посмотреть детали', 'View Details': 'Посмотреть детали',
'Follow Pack Not Found': 'Пакет подписок не найден', 'Follow Pack Not Found': 'Пакет подписок не найден',
'Follow pack not found': 'Пакет подписок не найден', 'Follow pack not found': 'Пакет подписок не найден',
Users: 'Пользователи', Users: 'Пользователи',
Feed: 'Лента', 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': 'รีเลย์ที่เหมาะสม', 'Optimal relays': 'รีเลย์ที่เหมาะสม',
"Successfully republish to optimal relays (your write relays and mentioned users' read 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': 'เนื้อหาภายนอก', 'External Content': 'เนื้อหาภายนอก',
Highlight: 'ไฮไลต์', Highlight: 'ไฮไลต์',
'Optimal relays and {{count}} other relays': 'รีเลย์ที่เหมาะสมและรีเลย์อื่น {{count}} รายการ', 'Optimal relays and {{count}} other relays': 'รีเลย์ที่เหมาะสมและรีเลย์อื่น {{count}} รายการ',
'Likely spam account (Trust score: {{percentile}}%)': 'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)', 'Likely spam account (Trust score: {{percentile}}%)':
'Suspicious account (Trust score: {{percentile}}%)': 'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)', 'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
'Suspicious account (Trust score: {{percentile}}%)':
'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
'n users': '{{count}} ผู้ใช้', 'n users': '{{count}} ผู้ใช้',
'View Details': 'ดูรายละเอียด', 'View Details': 'ดูรายละเอียด',
'Follow Pack Not Found': 'ไม่พบแพ็คการติดตาม', 'Follow Pack Not Found': 'ไม่พบแพ็คการติดตาม',
'Follow pack not found': 'ไม่พบแพ็คการติดตาม', 'Follow pack not found': 'ไม่พบแพ็คการติดตาม',
Users: 'ผู้ใช้', Users: 'ผู้ใช้',
Feed: 'ฟีด', 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': '外部内容', 'External Content': '外部内容',
Highlight: '高亮', Highlight: '高亮',
'Optimal relays and {{count}} other relays': '最佳中继器和其他 {{count}} 个中继器', '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}}%', 'Suspicious account (Trust score: {{percentile}}%)': '可疑账号(信任分数:{{percentile}}%',
'n users': '{{count}} 位用户', 'n users': '{{count}} 位用户',
'View Details': '查看详情', 'View Details': '查看详情',
@@ -552,6 +553,11 @@ export default {
'Follow pack not found': '未找到关注包', 'Follow pack not found': '未找到关注包',
Users: '用户', Users: '用户',
Feed: '动态', 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}` return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`
} }
export const toNjump = (id: string) => `https://njump.me/${id}` 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 NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { relayList, pubkey } = useNostr() const { pubkey } = useNostr()
const [title, setTitle] = useState<React.ReactNode>(null) const [title, setTitle] = useState<React.ReactNode>(null)
const [controls, setControls] = useState<React.ReactNode>(null) const [controls, setControls] = useState<React.ReactNode>(null)
const [data, setData] = useState< const [data, setData] = useState<
| { | {
type: 'hashtag' | 'search' | 'externalContent' type: 'hashtag' | 'search'
kinds?: number[] kinds?: number[]
} }
| { | {
@@ -64,18 +64,6 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
]) ])
return 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') const domain = searchParams.get('d')
if (domain) { if (domain) {
setTitle( setTitle(
@@ -119,7 +107,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
</div> </div>
) )
} else if (data) { } else if (data) {
content = <NormalFeed subRequests={subRequests} /> content = <NormalFeed subRequests={subRequests} disable24hMode={data.type !== 'domain'} />
} }
return ( 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 SettingsPage from '@/pages/secondary/SettingsPage'
import SystemSettingsPage from '@/pages/secondary/SystemSettingsPage' import SystemSettingsPage from '@/pages/secondary/SystemSettingsPage'
import TranslationPage from '@/pages/secondary/TranslationPage' import TranslationPage from '@/pages/secondary/TranslationPage'
import UserAggregationDetailPage from '@/pages/secondary/UserAggregationDetailPage'
import WalletPage from '@/pages/secondary/WalletPage' import WalletPage from '@/pages/secondary/WalletPage'
import { match } from 'path-to-regexp' import { match } from 'path-to-regexp'
import { isValidElement } from 'react' import { isValidElement } from 'react'
@@ -50,7 +51,8 @@ const SECONDARY_ROUTE_CONFIGS = [
{ path: '/mutes', element: <MuteListPage /> }, { path: '/mutes', element: <MuteListPage /> },
{ path: '/rizful', element: <RizfulPage /> }, { path: '/rizful', element: <RizfulPage /> },
{ path: '/bookmarks', element: <BookmarkPage /> }, { 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 }) => ({ export const SECONDARY_ROUTES = SECONDARY_ROUTE_CONFIGS.map(({ path, element }) => ({

View File

@@ -251,6 +251,7 @@ class ClientService extends EventTarget {
Object.entries(filter) Object.entries(filter)
.sort() .sort()
.forEach(([key, value]) => { .forEach(([key, value]) => {
if (key === 'limit') return
if (Array.isArray(value)) { if (Array.isArray(value)) {
stableFilter[key] = [...value].sort() stableFilter[key] = [...value].sort()
} }
@@ -298,7 +299,6 @@ class ClientService extends EventTarget {
const newEventIdSet = new Set<string>() const newEventIdSet = new Set<string>()
const requestCount = subRequests.length const requestCount = subRequests.length
const threshold = Math.floor(requestCount / 2) const threshold = Math.floor(requestCount / 2)
let eventIdSet = new Set<string>()
let events: NEvent[] = [] let events: NEvent[] = []
let eosedCount = 0 let eosedCount = 0
@@ -313,13 +313,7 @@ class ClientService extends EventTarget {
eosedCount++ eosedCount++
} }
_events.forEach((evt) => { events = this.mergeTimelines(events, _events)
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))
if (eosedCount >= threshold) { if (eosedCount >= threshold) {
onEvents(events, eosedCount >= requestCount) 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) { async loadMoreTimeline(key: string, until: number, limit: number) {
const timeline = this.timelines[key] const timeline = this.timelines[key]
if (!timeline) return [] if (!timeline) return []
@@ -552,9 +571,9 @@ class ClientService extends EventTarget {
let cachedEvents: NEvent[] = [] let cachedEvents: NEvent[] = []
let since: number | undefined let since: number | undefined
if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) { if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) {
cachedEvents = ( cachedEvents = (await this.eventDataLoader.loadMany(timeline.refs.map(([id]) => id))).filter(
await this.eventDataLoader.loadMany(timeline.refs.slice(0, filter.limit).map(([id]) => id)) (evt) => !!evt && !(evt instanceof Error)
).filter((evt) => !!evt && !(evt instanceof Error)) as NEvent[] ) as NEvent[]
if (cachedEvents.length) { if (cachedEvents.length) {
onEvents([...cachedEvents], false) onEvents([...cachedEvents], false)
since = cachedEvents[0].created_at + 1 since = cachedEvents[0].created_at + 1

View File

@@ -57,6 +57,7 @@ class LocalStorageService {
private enableSingleColumnLayout: boolean = true private enableSingleColumnLayout: boolean = true
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
private filterOutOnionRelays: boolean = !isTorBrowser() private filterOutOnionRelays: boolean = !isTorBrowser()
private pinnedPubkeys: Set<string> = new Set()
constructor() { constructor() {
if (!LocalStorageService.instance) { if (!LocalStorageService.instance) {
@@ -75,7 +76,7 @@ class LocalStorageService {
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE) const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
this.noteListMode = this.noteListMode =
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr) noteListModeStr && ['posts', 'postsAndReplies', '24h'].includes(noteListModeStr)
? (noteListModeStr as TNoteListMode) ? (noteListModeStr as TNoteListMode)
: 'posts' : 'posts'
const lastReadNotificationTimeMapStr = const lastReadNotificationTimeMapStr =
@@ -230,6 +231,11 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false' 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 // Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP) window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@@ -558,6 +564,18 @@ class LocalStorageService {
this.filterOutOnionRelays = filterOut this.filterOutOnionRelays = filterOut
window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString()) 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() 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 minPow?: number
} }
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | '24h'
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps' export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'