feat: 24h pulse
This commit is contained in:
@@ -84,7 +84,7 @@ export default function KindFilter({
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
className={cn(
|
||||
'relative w-fit px-3 focus:text-foreground',
|
||||
'relative w-fit px-3 hover:text-foreground',
|
||||
!isDifferentFromSaved && 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -94,7 +94,6 @@ export default function KindFilter({
|
||||
}}
|
||||
>
|
||||
<ListFilter size={16} />
|
||||
{t('Filter')}
|
||||
{isDifferentFromSaved && (
|
||||
<div className="absolute size-2 rounded-full bg-primary left-7 top-2 ring-2 ring-background" />
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import NoteList, { TNoteListRef } from '@/components/NoteList'
|
||||
import Tabs from '@/components/Tabs'
|
||||
import UserAggregationList, { TUserAggregationListRef } from '@/components/UserAggregationList'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import storage from '@/services/local-storage.service'
|
||||
import { TFeedSubRequest, TNoteListMode } from '@/types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import KindFilter from '../KindFilter'
|
||||
import { RefreshButton } from '../RefreshButton'
|
||||
@@ -13,12 +15,16 @@ export default function NormalFeed({
|
||||
subRequests,
|
||||
areAlgoRelays = false,
|
||||
isMainFeed = false,
|
||||
showRelayCloseReason = false
|
||||
showRelayCloseReason = false,
|
||||
filterFn,
|
||||
disable24hMode = false
|
||||
}: {
|
||||
subRequests: TFeedSubRequest[]
|
||||
areAlgoRelays?: boolean
|
||||
isMainFeed?: boolean
|
||||
showRelayCloseReason?: boolean
|
||||
filterFn?: (event: Event) => boolean
|
||||
disable24hMode?: boolean
|
||||
}) {
|
||||
const { hideUntrustedNotes } = useUserTrust()
|
||||
const { showKinds } = useKindFilter()
|
||||
@@ -26,13 +32,18 @@ export default function NormalFeed({
|
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
|
||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||
const noteListRef = useRef<TNoteListRef>(null)
|
||||
const userAggregationListRef = useRef<TUserAggregationListRef>(null)
|
||||
const topRef = useRef<HTMLDivElement>(null)
|
||||
const showKindsFilter = useMemo(() => {
|
||||
return subRequests.every((req) => !req.filter.kinds?.length)
|
||||
}, [subRequests])
|
||||
|
||||
const handleListModeChange = (mode: TNoteListMode) => {
|
||||
setListMode(mode)
|
||||
if (isMainFeed) {
|
||||
storage.setNoteListMode(mode)
|
||||
}
|
||||
noteListRef.current?.scrollToTop('smooth')
|
||||
topRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const handleShowKindsChange = (newShowKinds: number[]) => {
|
||||
@@ -43,21 +54,46 @@ export default function NormalFeed({
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
value={listMode}
|
||||
value={listMode === '24h' && disable24hMode ? 'posts' : listMode}
|
||||
tabs={[
|
||||
{ value: 'posts', label: 'Notes' },
|
||||
{ value: 'postsAndReplies', label: 'Replies' }
|
||||
{ value: 'postsAndReplies', label: 'Replies' },
|
||||
...(!disable24hMode ? [{ value: '24h', label: '24h Pulse' }] : [])
|
||||
]}
|
||||
onTabChange={(listMode) => {
|
||||
handleListModeChange(listMode as TNoteListMode)
|
||||
}}
|
||||
options={
|
||||
<>
|
||||
{!supportTouch && <RefreshButton onClick={() => noteListRef.current?.refresh()} />}
|
||||
<KindFilter showKinds={temporaryShowKinds} onShowKindsChange={handleShowKindsChange} />
|
||||
{!supportTouch && (
|
||||
<RefreshButton
|
||||
onClick={() => {
|
||||
if (listMode === '24h') {
|
||||
userAggregationListRef.current?.refresh()
|
||||
} else {
|
||||
noteListRef.current?.refresh()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showKindsFilter && (
|
||||
<KindFilter
|
||||
showKinds={temporaryShowKinds}
|
||||
onShowKindsChange={handleShowKindsChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div ref={topRef} className="scroll-mt-[calc(6rem+1px)]" />
|
||||
{listMode === '24h' && !disable24hMode ? (
|
||||
<UserAggregationList
|
||||
ref={userAggregationListRef}
|
||||
showKinds={temporaryShowKinds}
|
||||
subRequests={subRequests}
|
||||
filterFn={filterFn}
|
||||
/>
|
||||
) : (
|
||||
<NoteList
|
||||
ref={noteListRef}
|
||||
showKinds={temporaryShowKinds}
|
||||
@@ -67,6 +103,7 @@ export default function NormalFeed({
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
showRelayCloseReason={showRelayCloseReason}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,26 @@ const LIMIT = 200
|
||||
const ALGO_LIMIT = 500
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
const NoteList = forwardRef(
|
||||
export type TNoteListRef = {
|
||||
scrollToTop: (behavior?: ScrollBehavior) => void
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
const NoteList = forwardRef<
|
||||
TNoteListRef,
|
||||
{
|
||||
subRequests: TFeedSubRequest[]
|
||||
showKinds?: number[]
|
||||
filterMutedNotes?: boolean
|
||||
hideReplies?: boolean
|
||||
hideUntrustedNotes?: boolean
|
||||
areAlgoRelays?: boolean
|
||||
showRelayCloseReason?: boolean
|
||||
pinnedEventIds?: string[]
|
||||
filterFn?: (event: Event) => boolean
|
||||
showNewNotesDirectly?: boolean
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
subRequests,
|
||||
@@ -46,17 +65,6 @@ const NoteList = forwardRef(
|
||||
pinnedEventIds,
|
||||
filterFn,
|
||||
showNewNotesDirectly = false
|
||||
}: {
|
||||
subRequests: TFeedSubRequest[]
|
||||
showKinds?: number[]
|
||||
filterMutedNotes?: boolean
|
||||
hideReplies?: boolean
|
||||
hideUntrustedNotes?: boolean
|
||||
areAlgoRelays?: boolean
|
||||
showRelayCloseReason?: boolean
|
||||
pinnedEventIds?: string[]
|
||||
filterFn?: (event: Event) => boolean
|
||||
showNewNotesDirectly?: boolean
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -415,8 +423,3 @@ const NoteList = forwardRef(
|
||||
)
|
||||
NoteList.displayName = 'NoteList'
|
||||
export default NoteList
|
||||
|
||||
export type TNoteListRef = {
|
||||
scrollToTop: (behavior?: ScrollBehavior) => void
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
@@ -26,7 +26,13 @@ export default function ProfileFeed({
|
||||
const { pubkey: myPubkey, pinListEvent: myPinListEvent } = useNostr()
|
||||
const { showKinds } = useKindFilter()
|
||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => storage.getNoteListMode())
|
||||
const [listMode, setListMode] = useState<TNoteListMode>(() => {
|
||||
const mode = storage.getNoteListMode()
|
||||
if (mode === '24h') {
|
||||
return 'posts'
|
||||
}
|
||||
return mode
|
||||
})
|
||||
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
|
||||
const [pinnedEventIds, setPinnedEventIds] = useState<string[]>([])
|
||||
const tabs = useMemo(() => {
|
||||
|
||||
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',
|
||||
FAVICON_URL_TEMPLATE: 'faviconUrlTemplate',
|
||||
FILTER_OUT_ONION_RELAYS: 'filterOutOnionRelays',
|
||||
PINNED_PUBKEYS: 'pinnedPubkeys',
|
||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
|
||||
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated
|
||||
|
||||
@@ -547,11 +547,13 @@ export default {
|
||||
'Optimal relays': 'المرحلات المثلى',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'تم إعادة النشر بنجاح إلى المرحلات المثلى (مرحلات الكتابة الخاصة بك ومرحلات القراءة للمستخدمين المذكورين)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'فشل إعادة النشر إلى المرحلات المثلى: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'فشل إعادة النشر إلى المرحلات المثلى: {{error}}',
|
||||
'External Content': 'محتوى خارجي',
|
||||
Highlight: 'تسليط الضوء',
|
||||
'Optimal relays and {{count}} other relays': 'المرحلات المثلى و {{count}} مرحلات أخرى',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'حساب مشبوه للغاية (درجة الثقة: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'حساب مشبوه للغاية (درجة الثقة: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشبوه (درجة الثقة: {{percentile}}%)',
|
||||
'n users': '{{count}} مستخدمين',
|
||||
'View Details': 'عرض التفاصيل',
|
||||
@@ -559,6 +561,11 @@ export default {
|
||||
'Follow pack not found': 'لم يتم العثور على حزمة المتابعة',
|
||||
Users: 'المستخدمون',
|
||||
Feed: 'التغذية',
|
||||
'Follow Pack': 'حزمة المتابعة'
|
||||
'Follow Pack': 'حزمة المتابعة',
|
||||
'24h Pulse': 'النبض 24 ساعة',
|
||||
'Load earlier': 'تحميل سابق',
|
||||
'Last 24 hours': 'آخر 24 ساعة',
|
||||
'Last {{count}} days': 'آخر {{count}} أيام',
|
||||
notes: 'ملاحظات'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,14 +567,21 @@ export default {
|
||||
'External Content': 'Externer Inhalt',
|
||||
Highlight: 'Hervorheben',
|
||||
'Optimal relays and {{count}} other relays': 'Optimale Relays und {{count}} andere Relays',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Wahrscheinlich Spam-Konto (Vertrauenswert: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Verdächtiges Konto (Vertrauenswert: {{percentile}}%)',
|
||||
'n users': '{{count}} Benutzer',
|
||||
'View Details': 'Details anzeigen',
|
||||
'Follow Pack Not Found': 'Follow-Pack nicht gefunden',
|
||||
'Follow pack not found': 'Follow-Pack nicht gefunden',
|
||||
Users: 'Benutzer',
|
||||
Feed: 'Feed',
|
||||
'Follow Pack': 'Follow-Pack'
|
||||
'Follow Pack': 'Follow-Pack',
|
||||
'24h Pulse': '24h Pulse',
|
||||
'Load earlier': 'Früher laden',
|
||||
'Last 24 hours': 'Letzte 24 Stunden',
|
||||
'Last {{count}} days': 'Letzte {{count}} Tage',
|
||||
notes: 'Notizen'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,6 +564,11 @@ export default {
|
||||
'Follow pack not found': 'Follow pack not found',
|
||||
Users: 'Users',
|
||||
Feed: 'Feed',
|
||||
'Follow Pack': 'Follow Pack'
|
||||
'Follow Pack': 'Follow Pack',
|
||||
'24h Pulse': '24h Pulse',
|
||||
'Load earlier': 'Load earlier',
|
||||
'Last 24 hours': 'Last 24 hours',
|
||||
'Last {{count}} days': 'Last {{count}} days',
|
||||
notes: 'notes'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,18 +558,26 @@ export default {
|
||||
'Optimal relays': 'Relays óptimos',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'Republicado exitosamente en relays óptimos (tus relays de escritura y los relays de lectura de los usuarios mencionados)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'Error al republicar en relays óptimos: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'Error al republicar en relays óptimos: {{error}}',
|
||||
'External Content': 'Contenido externo',
|
||||
Highlight: 'Destacado',
|
||||
'Optimal relays and {{count}} other relays': 'Relays óptimos y {{count}} otros relays',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Probablemente cuenta spam (Puntuación de confianza: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Cuenta sospechosa (Puntuación de confianza: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Probablemente cuenta spam (Puntuación de confianza: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Cuenta sospechosa (Puntuación de confianza: {{percentile}}%)',
|
||||
'n users': '{{count}} usuarios',
|
||||
'View Details': 'Ver detalles',
|
||||
'Follow Pack Not Found': 'Paquete de seguimiento no encontrado',
|
||||
'Follow pack not found': 'Paquete de seguimiento no encontrado',
|
||||
Users: 'Usuarios',
|
||||
Feed: 'Feed',
|
||||
'Follow Pack': 'Paquete de Seguimiento'
|
||||
'Follow Pack': 'Paquete de Seguimiento',
|
||||
'24h Pulse': 'Pulso 24h',
|
||||
'Load earlier': 'Cargar anterior',
|
||||
'Last 24 hours': 'Últimas 24 horas',
|
||||
'Last {{count}} days': 'Últimos {{count}} días',
|
||||
notes: 'notas'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,18 +552,26 @@ export default {
|
||||
'Optimal relays': 'رلههای بهینه',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'با موفقیت در رلههای بهینه منتشر شد (رلههای نوشتن شما و رلههای خواندن کاربران ذکر شده)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'خطا در انتشار مجدد در رلههای بهینه: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'خطا در انتشار مجدد در رلههای بهینه: {{error}}',
|
||||
'External Content': 'محتوای خارجی',
|
||||
Highlight: 'برجستهسازی',
|
||||
'Optimal relays and {{count}} other relays': 'رلههای بهینه و {{count}} رله دیگر',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'احتمالاً حساب هرزنامه (امتیاز اعتماد: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'حساب مشکوک (امتیاز اعتماد: {{percentile}}%)',
|
||||
'n users': '{{count}} کاربر',
|
||||
'View Details': 'مشاهده جزئیات',
|
||||
'Follow Pack Not Found': 'بسته دنبالکننده یافت نشد',
|
||||
'Follow pack not found': 'بسته دنبالکننده یافت نشد',
|
||||
Users: 'کاربران',
|
||||
Feed: 'فید',
|
||||
'Follow Pack': 'بسته دنبالکننده'
|
||||
'Follow Pack': 'بسته دنبالکننده',
|
||||
'24h Pulse': 'نبض 24 ساعته',
|
||||
'Load earlier': 'بارگذاری قدیمیتر',
|
||||
'Last 24 hours': '24 ساعت گذشته',
|
||||
'Last {{count}} days': '{{count}} روز گذشته',
|
||||
notes: 'یادداشتها'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,18 +561,26 @@ export default {
|
||||
'Optimal relays': 'Relais optimaux',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
"Republié avec succès sur les relais optimaux (vos relais d'écriture et les relais de lecture des utilisateurs mentionnés)",
|
||||
'Failed to republish to optimal relays: {{error}}': 'Échec de la republication sur les relais optimaux : {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'Échec de la republication sur les relais optimaux : {{error}}',
|
||||
'External Content': 'Contenu externe',
|
||||
Highlight: 'Surligner',
|
||||
'Optimal relays and {{count}} other relays': 'Relais optimaux et {{count}} autres relais',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Compte probablement spam (Score de confiance: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Compte suspect (Score de confiance: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Compte probablement spam (Score de confiance: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Compte suspect (Score de confiance: {{percentile}}%)',
|
||||
'n users': '{{count}} utilisateurs',
|
||||
'View Details': 'Voir les détails',
|
||||
'Follow Pack Not Found': 'Pack de suivi introuvable',
|
||||
'Follow pack not found': 'Pack de suivi introuvable',
|
||||
Users: 'Utilisateurs',
|
||||
Feed: 'Flux',
|
||||
'Follow Pack': 'Pack de Suivi'
|
||||
'Follow Pack': 'Pack de Suivi',
|
||||
'24h Pulse': 'Pulse 24h',
|
||||
'Load earlier': 'Charger plus tôt',
|
||||
'Last 24 hours': 'Dernières 24 heures',
|
||||
'Last {{count}} days': 'Derniers {{count}} jours',
|
||||
notes: 'notes'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,18 +553,26 @@ export default {
|
||||
'Optimal relays': 'इष्टतम रिले',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'इष्टतम रिले पर सफलतापूर्वक पुनः प्रकाशित (आपके लेखन रिले और उल्लिखित उपयोगकर्ताओं के पठन रिले)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'इष्टतम रिले पर पुनः प्रकाशित करने में विफल: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'इष्टतम रिले पर पुनः प्रकाशित करने में विफल: {{error}}',
|
||||
'External Content': 'बाहरी सामग्री',
|
||||
Highlight: 'हाइलाइट',
|
||||
'Optimal relays and {{count}} other relays': 'इष्टतम रिले और {{count}} अन्य रिले',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'संभावित स्पैम खाता (विश्वास स्कोर: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'संदिग्ध खाता (विश्वास स्कोर: {{percentile}}%)',
|
||||
'n users': '{{count}} उपयोगकर्ता',
|
||||
'View Details': 'विवरण देखें',
|
||||
'Follow Pack Not Found': 'फॉलो पैक नहीं मिला',
|
||||
'Follow pack not found': 'फॉलो पैक नहीं मिला',
|
||||
Users: 'उपयोगकर्ता',
|
||||
Feed: 'फ़ीड',
|
||||
'Follow Pack': 'फॉलो पैक'
|
||||
'Follow Pack': 'फॉलो पैक',
|
||||
'24h Pulse': '24h पल्स',
|
||||
'Load earlier': 'पहले लोड करें',
|
||||
'Last 24 hours': 'पिछले 24 घंटे',
|
||||
'Last {{count}} days': 'पिछले {{count}} दिन',
|
||||
notes: 'नोट्स'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,14 +552,21 @@ export default {
|
||||
'External Content': 'Külső tartalom',
|
||||
Highlight: 'Kiemelés',
|
||||
'Optimal relays and {{count}} other relays': 'Optimális relay-ek és {{count}} másik relay',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Valószínűleg spam fiók (Megbízhatósági pontszám: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Gyanús fiók (Megbízhatósági pontszám: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Valószínűleg spam fiók (Megbízhatósági pontszám: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Gyanús fiók (Megbízhatósági pontszám: {{percentile}}%)',
|
||||
'n users': '{{count}} felhasználó',
|
||||
'View Details': 'Részletek megtekintése',
|
||||
'Follow Pack Not Found': 'Követési csomag nem található',
|
||||
'Follow pack not found': 'Követési csomag nem található',
|
||||
Users: 'Felhasználók',
|
||||
Feed: 'Hírfolyam',
|
||||
'Follow Pack': 'Követési Csomag'
|
||||
'Follow Pack': 'Követési Csomag',
|
||||
'24h Pulse': '24h Pulse',
|
||||
'Load earlier': 'Korábbi betöltése',
|
||||
'Last 24 hours': 'Utolsó 24 óra',
|
||||
'Last {{count}} days': 'Utolsó {{count}} nap',
|
||||
notes: 'jegyzetek'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,18 +557,26 @@ export default {
|
||||
'Optimal relays': 'Relay ottimali',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'Ripubblicato con successo sui relay ottimali (i tuoi relay di scrittura e i relay di lettura degli utenti menzionati)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'Errore nella ripubblicazione sui relay ottimali: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'Errore nella ripubblicazione sui relay ottimali: {{error}}',
|
||||
'External Content': 'Contenuto esterno',
|
||||
Highlight: 'Evidenzia',
|
||||
'Optimal relays and {{count}} other relays': 'Relay ottimali e {{count}} altri relay',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Probabile account spam (Punteggio di fiducia: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Account sospetto (Punteggio di fiducia: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Probabile account spam (Punteggio di fiducia: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Account sospetto (Punteggio di fiducia: {{percentile}}%)',
|
||||
'n users': '{{count}} utenti',
|
||||
'View Details': 'Visualizza dettagli',
|
||||
'Follow Pack Not Found': 'Pacchetto di follow non trovato',
|
||||
'Follow pack not found': 'Pacchetto di follow non trovato',
|
||||
Users: 'Utenti',
|
||||
Feed: 'Feed',
|
||||
'Follow Pack': 'Pacchetto di Follow'
|
||||
'Follow Pack': 'Pacchetto di Follow',
|
||||
'24h Pulse': 'Pulse 24h',
|
||||
'Load earlier': 'Carica precedente',
|
||||
'Last 24 hours': 'Ultime 24 ore',
|
||||
'Last {{count}} days': 'Ultimi {{count}} giorni',
|
||||
notes: 'note'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,18 +552,26 @@ export default {
|
||||
'Optimal relays': '最適なリレー',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'最適なリレー(あなたの書き込みリレーと言及されたユーザーの読み取りリレー)への再公開に成功しました',
|
||||
'Failed to republish to optimal relays: {{error}}': '最適なリレーへの再公開に失敗しました:{{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'最適なリレーへの再公開に失敗しました:{{error}}',
|
||||
'External Content': '外部コンテンツ',
|
||||
Highlight: 'ハイライト',
|
||||
'Optimal relays and {{count}} other relays': '最適なリレーと他の{{count}}個のリレー',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': '疑わしいアカウント(信頼スコア:{{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'スパムの可能性が高いアカウント(信頼スコア:{{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'疑わしいアカウント(信頼スコア:{{percentile}}%)',
|
||||
'n users': '{{count}}人のユーザー',
|
||||
'View Details': '詳細を表示',
|
||||
'Follow Pack Not Found': 'フォローパックが見つかりません',
|
||||
'Follow pack not found': 'フォローパックが見つかりません',
|
||||
Users: 'ユーザー',
|
||||
Feed: 'フィード',
|
||||
'Follow Pack': 'フォローパック'
|
||||
'Follow Pack': 'フォローパック',
|
||||
'24h Pulse': '24h パルス',
|
||||
'Load earlier': '以前を読み込む',
|
||||
'Last 24 hours': '過去24時間',
|
||||
'Last {{count}} days': '過去{{count}}日間',
|
||||
notes: 'ノート'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,14 +556,21 @@ export default {
|
||||
'External Content': '외부 콘텐츠',
|
||||
Highlight: '하이라이트',
|
||||
'Optimal relays and {{count}} other relays': '최적 릴레이 및 기타 {{count}}개 릴레이',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': '스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': '의심스러운 계정 (신뢰 점수: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'스팸 계정 가능성 높음 (신뢰 점수: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'의심스러운 계정 (신뢰 점수: {{percentile}}%)',
|
||||
'n users': '{{count}}명의 사용자',
|
||||
'View Details': '세부 정보 보기',
|
||||
'Follow Pack Not Found': '팔로우 팩을 찾을 수 없음',
|
||||
'Follow pack not found': '팔로우 팩을 찾을 수 없습니다',
|
||||
Users: '사용자',
|
||||
Feed: '피드',
|
||||
'Follow Pack': '팔로우 팩'
|
||||
'Follow Pack': '팔로우 팩',
|
||||
'24h Pulse': '24h 펄스',
|
||||
'Load earlier': '이전 데이터 로드',
|
||||
'Last 24 hours': '최근 24시간',
|
||||
'Last {{count}} days': '최근 {{count}}일',
|
||||
notes: '노트'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,15 +561,23 @@ export default {
|
||||
'Nie udało się opublikować ponownie na optymalnych przekaźnikach: {{error}}',
|
||||
'External Content': 'Treść zewnętrzna',
|
||||
Highlight: 'Podświetl',
|
||||
'Optimal relays and {{count}} other relays': 'Optymalne przekaźniki i {{count}} innych przekaźników',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Prawdopodobnie konto spamowe (Wynik zaufania: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Podejrzane konto (Wynik zaufania: {{percentile}}%)',
|
||||
'Optimal relays and {{count}} other relays':
|
||||
'Optymalne przekaźniki i {{count}} innych przekaźników',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Prawdopodobnie konto spamowe (Wynik zaufania: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Podejrzane konto (Wynik zaufania: {{percentile}}%)',
|
||||
'n users': '{{count}} użytkowników',
|
||||
'View Details': 'Zobacz szczegóły',
|
||||
'Follow Pack Not Found': 'Nie znaleziono pakietu obserwowanych',
|
||||
'Follow pack not found': 'Nie znaleziono pakietu obserwowanych',
|
||||
Users: 'Użytkownicy',
|
||||
Feed: 'Kanał',
|
||||
'Follow Pack': 'Pakiet Obserwowanych'
|
||||
'Follow Pack': 'Pakiet Obserwowanych',
|
||||
'24h Pulse': '24h Pulse',
|
||||
'Load earlier': 'Załaduj wcześniejsze',
|
||||
'Last 24 hours': 'Ostatnie 24 godziny',
|
||||
'Last {{count}} days': 'Ostatnie {{count}} dni',
|
||||
notes: 'notatki'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,18 +553,26 @@ export default {
|
||||
'Optimal relays': 'Relays ideais',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'Republicado com sucesso nos relays ideais (seus relays de escrita e os relays de leitura dos usuários mencionados)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'Falha ao republicar nos relays ideais: {{error}}',
|
||||
'External Content': 'Conteúdo externo',
|
||||
Highlight: 'Marcação',
|
||||
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
|
||||
'n users': '{{count}} usuários',
|
||||
'View Details': 'Ver detalhes',
|
||||
'Follow Pack Not Found': 'Pacote de seguir não encontrado',
|
||||
'Follow pack not found': 'Pacote de seguir não encontrado',
|
||||
Users: 'Usuários',
|
||||
Feed: 'Feed',
|
||||
'Follow Pack': 'Pacote de Seguir'
|
||||
'Follow Pack': 'Pacote de Seguir',
|
||||
'24h Pulse': 'Pulso 24h',
|
||||
'Load earlier': 'Carregar anterior',
|
||||
'Last 24 hours': 'Últimas 24 horas',
|
||||
'Last {{count}} days': 'Últimos {{count}} dias',
|
||||
notes: 'notas'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,18 +556,26 @@ export default {
|
||||
'Optimal relays': 'Relays ideais',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'Republicado com sucesso nos relays ideais (os seus relays de escrita e os relays de leitura dos utilizadores mencionados)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'Falha ao republicar nos relays ideais: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'Falha ao republicar nos relays ideais: {{error}}',
|
||||
'External Content': 'Conteúdo externo',
|
||||
Highlight: 'Destacar',
|
||||
'Optimal relays and {{count}} other relays': 'Relays ideais e {{count}} outros relays',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Provável conta de spam (Pontuação de confiança: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Conta suspeita (Pontuação de confiança: {{percentile}}%)',
|
||||
'n users': '{{count}} utilizadores',
|
||||
'View Details': 'Ver detalhes',
|
||||
'Follow Pack Not Found': 'Pacote de seguir não encontrado',
|
||||
'Follow pack not found': 'Pacote de seguir não encontrado',
|
||||
Users: 'Utilizadores',
|
||||
Feed: 'Feed',
|
||||
'Follow Pack': 'Pacote de Seguir'
|
||||
'Follow Pack': 'Pacote de Seguir',
|
||||
'24h Pulse': 'Pulso 24h',
|
||||
'Load earlier': 'Carregar anterior',
|
||||
'Last 24 hours': 'Últimas 24 horas',
|
||||
'Last {{count}} days': 'Últimos {{count}} dias',
|
||||
notes: 'notas'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,18 +558,26 @@ export default {
|
||||
'Optimal relays': 'Оптимальные релеи',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'Успешно опубликовано в оптимальные релеи (ваши релеи для записи и релеи для чтения упомянутых пользователей)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'Не удалось опубликовать в оптимальные релеи: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'Не удалось опубликовать в оптимальные релеи: {{error}}',
|
||||
'External Content': 'Внешний контент',
|
||||
Highlight: 'Выделить',
|
||||
'Optimal relays and {{count}} other relays': 'Оптимальные релеи и {{count}} других релеев',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'Вероятно спам-аккаунт (Оценка доверия: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'Подозрительный аккаунт (Оценка доверия: {{percentile}}%)',
|
||||
'n users': '{{count}} пользователей',
|
||||
'View Details': 'Посмотреть детали',
|
||||
'Follow Pack Not Found': 'Пакет подписок не найден',
|
||||
'Follow pack not found': 'Пакет подписок не найден',
|
||||
Users: 'Пользователи',
|
||||
Feed: 'Лента',
|
||||
'Follow Pack': 'Пакет Подписок'
|
||||
'Follow Pack': 'Пакет Подписок',
|
||||
'24h Pulse': 'Пульс 24ч',
|
||||
'Load earlier': 'Загрузить ранее',
|
||||
'Last 24 hours': 'Последние 24 часа',
|
||||
'Last {{count}} days': 'Последние {{count}} дней',
|
||||
notes: 'заметки'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,18 +545,26 @@ export default {
|
||||
'Optimal relays': 'รีเลย์ที่เหมาะสม',
|
||||
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)":
|
||||
'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมสำเร็จ (รีเลย์เขียนของคุณและรีเลย์อ่านของผู้ใช้ที่กล่าวถึง)',
|
||||
'Failed to republish to optimal relays: {{error}}': 'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมล้มเหลว: {{error}}',
|
||||
'Failed to republish to optimal relays: {{error}}':
|
||||
'เผยแพร่ซ้ำไปยังรีเลย์ที่เหมาะสมล้มเหลว: {{error}}',
|
||||
'External Content': 'เนื้อหาภายนอก',
|
||||
Highlight: 'ไฮไลต์',
|
||||
'Optimal relays and {{count}} other relays': 'รีเลย์ที่เหมาะสมและรีเลย์อื่น {{count}} รายการ',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': 'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': 'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'น่าจะเป็นบัญชีสแปม (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)':
|
||||
'บัญชีที่น่าสงสัย (คะแนนความน่าเชื่อถือ: {{percentile}}%)',
|
||||
'n users': '{{count}} ผู้ใช้',
|
||||
'View Details': 'ดูรายละเอียด',
|
||||
'Follow Pack Not Found': 'ไม่พบแพ็คการติดตาม',
|
||||
'Follow pack not found': 'ไม่พบแพ็คการติดตาม',
|
||||
Users: 'ผู้ใช้',
|
||||
Feed: 'ฟีด',
|
||||
'Follow Pack': 'แพ็คการติดตาม'
|
||||
'Follow Pack': 'แพ็คการติดตาม',
|
||||
'24h Pulse': '24h พัลส์',
|
||||
'Load earlier': 'โหลดข้อมูลก่อนหน้า',
|
||||
'Last 24 hours': '24 ชั่วโมงที่แล้ว',
|
||||
'Last {{count}} days': '{{count}} วันที่แล้ว',
|
||||
notes: 'โน้ต'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +544,8 @@ export default {
|
||||
'External Content': '外部内容',
|
||||
Highlight: '高亮',
|
||||
'Optimal relays and {{count}} other relays': '最佳中继器和其他 {{count}} 个中继器',
|
||||
'Likely spam account (Trust score: {{percentile}}%)': '疑似垃圾账号(信任分数:{{percentile}}%)',
|
||||
'Likely spam account (Trust score: {{percentile}}%)':
|
||||
'疑似垃圾账号(信任分数:{{percentile}}%)',
|
||||
'Suspicious account (Trust score: {{percentile}}%)': '可疑账号(信任分数:{{percentile}}%)',
|
||||
'n users': '{{count}} 位用户',
|
||||
'View Details': '查看详情',
|
||||
@@ -552,6 +553,11 @@ export default {
|
||||
'Follow pack not found': '未找到关注包',
|
||||
Users: '用户',
|
||||
Feed: '动态',
|
||||
'Follow Pack': '关注包'
|
||||
'Follow Pack': '关注包',
|
||||
'24h Pulse': '24h 动态',
|
||||
'Load earlier': '加载更早',
|
||||
'Last 24 hours': '最近 24 小时',
|
||||
'Last {{count}} days': '最近 {{count}} 天',
|
||||
notes: '笔记'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,3 +87,7 @@ export const toChachiChat = (relay: string, d: string) => {
|
||||
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`
|
||||
}
|
||||
export const toNjump = (id: string) => `https://njump.me/${id}`
|
||||
export const toUserAggregationDetail = (feedId: string, pubkey: string) => {
|
||||
const npub = nip19.npubEncode(pubkey)
|
||||
return `/user-aggregation/${feedId}/${npub}`
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ import { useTranslation } from 'react-i18next'
|
||||
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { relayList, pubkey } = useNostr()
|
||||
const { pubkey } = useNostr()
|
||||
const [title, setTitle] = useState<React.ReactNode>(null)
|
||||
const [controls, setControls] = useState<React.ReactNode>(null)
|
||||
const [data, setData] = useState<
|
||||
| {
|
||||
type: 'hashtag' | 'search' | 'externalContent'
|
||||
type: 'hashtag' | 'search'
|
||||
kinds?: number[]
|
||||
}
|
||||
| {
|
||||
@@ -64,18 +64,6 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
])
|
||||
return
|
||||
}
|
||||
const externalContentId = searchParams.get('i')
|
||||
if (externalContentId) {
|
||||
setData({ type: 'externalContent' })
|
||||
setTitle(externalContentId)
|
||||
setSubRequests([
|
||||
{
|
||||
filter: { '#I': [externalContentId], ...(kinds.length > 0 ? { kinds } : {}) },
|
||||
urls: BIG_RELAY_URLS.concat(relayList?.write || [])
|
||||
}
|
||||
])
|
||||
return
|
||||
}
|
||||
const domain = searchParams.get('d')
|
||||
if (domain) {
|
||||
setTitle(
|
||||
@@ -119,7 +107,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
</div>
|
||||
)
|
||||
} else if (data) {
|
||||
content = <NormalFeed subRequests={subRequests} />
|
||||
content = <NormalFeed subRequests={subRequests} disable24hMode={data.type !== 'domain'} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
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 SystemSettingsPage from '@/pages/secondary/SystemSettingsPage'
|
||||
import TranslationPage from '@/pages/secondary/TranslationPage'
|
||||
import UserAggregationDetailPage from '@/pages/secondary/UserAggregationDetailPage'
|
||||
import WalletPage from '@/pages/secondary/WalletPage'
|
||||
import { match } from 'path-to-regexp'
|
||||
import { isValidElement } from 'react'
|
||||
@@ -50,7 +51,8 @@ const SECONDARY_ROUTE_CONFIGS = [
|
||||
{ path: '/mutes', element: <MuteListPage /> },
|
||||
{ path: '/rizful', element: <RizfulPage /> },
|
||||
{ path: '/bookmarks', element: <BookmarkPage /> },
|
||||
{ path: '/follow-packs/:id', element: <FollowPackPage /> }
|
||||
{ path: '/follow-packs/:id', element: <FollowPackPage /> },
|
||||
{ path: '/user-aggregation/:feedId/:npub', element: <UserAggregationDetailPage /> }
|
||||
]
|
||||
|
||||
export const SECONDARY_ROUTES = SECONDARY_ROUTE_CONFIGS.map(({ path, element }) => ({
|
||||
|
||||
@@ -251,6 +251,7 @@ class ClientService extends EventTarget {
|
||||
Object.entries(filter)
|
||||
.sort()
|
||||
.forEach(([key, value]) => {
|
||||
if (key === 'limit') return
|
||||
if (Array.isArray(value)) {
|
||||
stableFilter[key] = [...value].sort()
|
||||
}
|
||||
@@ -298,7 +299,6 @@ class ClientService extends EventTarget {
|
||||
const newEventIdSet = new Set<string>()
|
||||
const requestCount = subRequests.length
|
||||
const threshold = Math.floor(requestCount / 2)
|
||||
let eventIdSet = new Set<string>()
|
||||
let events: NEvent[] = []
|
||||
let eosedCount = 0
|
||||
|
||||
@@ -313,13 +313,7 @@ class ClientService extends EventTarget {
|
||||
eosedCount++
|
||||
}
|
||||
|
||||
_events.forEach((evt) => {
|
||||
if (eventIdSet.has(evt.id)) return
|
||||
eventIdSet.add(evt.id)
|
||||
events.push(evt)
|
||||
})
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
eventIdSet = new Set(events.map((evt) => evt.id))
|
||||
events = this.mergeTimelines(events, _events)
|
||||
|
||||
if (eosedCount >= threshold) {
|
||||
onEvents(events, eosedCount >= requestCount)
|
||||
@@ -352,6 +346,31 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] {
|
||||
if (a.length === 0) return [...b]
|
||||
if (b.length === 0) return [...a]
|
||||
|
||||
const result: NEvent[] = []
|
||||
let i = 0
|
||||
let j = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
const cmp = compareEvents(a[i], b[j])
|
||||
if (cmp > 0) {
|
||||
result.push(a[i])
|
||||
i++
|
||||
} else if (cmp < 0) {
|
||||
result.push(b[j])
|
||||
j++
|
||||
} else {
|
||||
result.push(a[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async loadMoreTimeline(key: string, until: number, limit: number) {
|
||||
const timeline = this.timelines[key]
|
||||
if (!timeline) return []
|
||||
@@ -552,9 +571,9 @@ class ClientService extends EventTarget {
|
||||
let cachedEvents: NEvent[] = []
|
||||
let since: number | undefined
|
||||
if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) {
|
||||
cachedEvents = (
|
||||
await this.eventDataLoader.loadMany(timeline.refs.slice(0, filter.limit).map(([id]) => id))
|
||||
).filter((evt) => !!evt && !(evt instanceof Error)) as NEvent[]
|
||||
cachedEvents = (await this.eventDataLoader.loadMany(timeline.refs.map(([id]) => id))).filter(
|
||||
(evt) => !!evt && !(evt instanceof Error)
|
||||
) as NEvent[]
|
||||
if (cachedEvents.length) {
|
||||
onEvents([...cachedEvents], false)
|
||||
since = cachedEvents[0].created_at + 1
|
||||
|
||||
@@ -57,6 +57,7 @@ class LocalStorageService {
|
||||
private enableSingleColumnLayout: boolean = true
|
||||
private faviconUrlTemplate: string = DEFAULT_FAVICON_URL_TEMPLATE
|
||||
private filterOutOnionRelays: boolean = !isTorBrowser()
|
||||
private pinnedPubkeys: Set<string> = new Set()
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
@@ -75,7 +76,7 @@ class LocalStorageService {
|
||||
this.currentAccount = currentAccountStr ? JSON.parse(currentAccountStr) : null
|
||||
const noteListModeStr = window.localStorage.getItem(StorageKey.NOTE_LIST_MODE)
|
||||
this.noteListMode =
|
||||
noteListModeStr && ['posts', 'postsAndReplies', 'pictures'].includes(noteListModeStr)
|
||||
noteListModeStr && ['posts', 'postsAndReplies', '24h'].includes(noteListModeStr)
|
||||
? (noteListModeStr as TNoteListMode)
|
||||
: 'posts'
|
||||
const lastReadNotificationTimeMapStr =
|
||||
@@ -230,6 +231,11 @@ class LocalStorageService {
|
||||
this.filterOutOnionRelays = filterOutOnionRelaysStr !== 'false'
|
||||
}
|
||||
|
||||
const pinnedPubkeysStr = window.localStorage.getItem(StorageKey.PINNED_PUBKEYS)
|
||||
if (pinnedPubkeysStr) {
|
||||
this.pinnedPubkeys = new Set(JSON.parse(pinnedPubkeysStr))
|
||||
}
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
|
||||
@@ -558,6 +564,18 @@ class LocalStorageService {
|
||||
this.filterOutOnionRelays = filterOut
|
||||
window.localStorage.setItem(StorageKey.FILTER_OUT_ONION_RELAYS, filterOut.toString())
|
||||
}
|
||||
|
||||
getPinnedPubkeys(): Set<string> {
|
||||
return this.pinnedPubkeys
|
||||
}
|
||||
|
||||
setPinnedPubkeys(pinnedPubkeys: Set<string>) {
|
||||
this.pinnedPubkeys = pinnedPubkeys
|
||||
window.localStorage.setItem(
|
||||
StorageKey.PINNED_PUBKEYS,
|
||||
JSON.stringify(Array.from(this.pinnedPubkeys))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you'
|
||||
export type TNoteListMode = 'posts' | 'postsAndReplies' | 'you' | '24h'
|
||||
|
||||
export type TNotificationType = 'all' | 'mentions' | 'reactions' | 'zaps'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user