feat: 24h pulse
This commit is contained in:
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
466
src/components/UserAggregationList/index.tsx
Normal file
466
src/components/UserAggregationList/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: 'ملاحظات'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'یادداشتها'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'नोट्स'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'ノート'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '노트'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'заметки'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'โน้ต'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '笔记'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
86
src/pages/secondary/UserAggregationDetailPage/index.tsx
Normal file
86
src/pages/secondary/UserAggregationDetailPage/index.tsx
Normal 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
|
||||||
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
207
src/services/user-aggregation.service.ts
Normal file
207
src/services/user-aggregation.service.ts
Normal 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
|
||||||
2
src/types/index.d.ts
vendored
2
src/types/index.d.ts
vendored
@@ -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'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user