Compare commits
19 Commits
feat-onboa
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60a460480 | ||
|
|
81667112d1 | ||
|
|
c60d7ab401 | ||
|
|
e25902b8b4 | ||
|
|
d964c7b7b3 | ||
|
|
25b2831fcc | ||
|
|
1553227e13 | ||
|
|
f04981f5b9 | ||
|
|
2662373704 | ||
|
|
526b64aec0 | ||
|
|
41a65338b5 | ||
|
|
56f0aa9fd5 | ||
|
|
89f79b999c | ||
|
|
7459a3d33a | ||
|
|
49eca495f5 | ||
|
|
96abe5f24f | ||
|
|
0ee93718da | ||
|
|
a880a92748 | ||
|
|
cd7c52eda0 |
15
src/App.tsx
15
src/App.tsx
@@ -15,7 +15,6 @@ import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||
import { NostrProvider } from '@/providers/NostrProvider'
|
||||
import { PinListProvider } from '@/providers/PinListProvider'
|
||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||
import { ReplyProvider } from '@/providers/ReplyProvider'
|
||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
||||
@@ -43,14 +42,12 @@ export default function App(): JSX.Element {
|
||||
<PinListProvider>
|
||||
<PinnedUsersProvider>
|
||||
<FeedProvider>
|
||||
<ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</FeedProvider>
|
||||
</PinnedUsersProvider>
|
||||
</PinListProvider>
|
||||
|
||||
@@ -110,8 +110,8 @@ export default function Signup({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2 items-center">
|
||||
<Button onClick={handleDownload} className="w-full">
|
||||
<div className="w-full flex flex-wrap gap-2">
|
||||
<Button onClick={handleDownload} className="flex-1">
|
||||
<Download />
|
||||
{t('Download Backup File')}
|
||||
</Button>
|
||||
@@ -122,7 +122,7 @@ export default function Signup({
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
className="flex-1"
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
{copied ? t('Copied to Clipboard') : t('Copy to Clipboard')}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { recommendRelaysByLanguage } from '@/lib/relay'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import { TAwesomeRelayCollection } from '@/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelaySimpleInfo, { RelaySimpleInfoSkeleton } from '../RelaySimpleInfo'
|
||||
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export default function Explore() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [collections, setCollections] = useState<TAwesomeRelayCollection[] | null>(null)
|
||||
const recommendedRelays = useMemo(() => {
|
||||
const lang = i18n.language
|
||||
const relays = recommendRelaysByLanguage(lang)
|
||||
return relays
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
relayInfoService.getAwesomeRelayCollections().then(setCollections)
|
||||
}, [])
|
||||
|
||||
if (!collections) {
|
||||
if (!collections && recommendedRelays.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 max-md:border-b">
|
||||
@@ -31,9 +39,19 @@ export default function Explore() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{collections.map((collection) => (
|
||||
<RelayCollection key={collection.id} collection={collection} />
|
||||
))}
|
||||
{recommendedRelays.length > 0 && (
|
||||
<RelayCollection
|
||||
collection={{
|
||||
id: 'recommended',
|
||||
name: t('Recommended'),
|
||||
relays: recommendedRelays
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{collections &&
|
||||
collections.map((collection) => (
|
||||
<RelayCollection key={collection.id} collection={collection} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
|
||||
export default function ExternalContentInteractions({
|
||||
pageIndex,
|
||||
externalContent
|
||||
}: {
|
||||
pageIndex?: number
|
||||
externalContent: string
|
||||
}) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
||||
list = <ReplyNoteList stuff={externalContent} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList stuff={externalContent} />
|
||||
|
||||
@@ -73,13 +73,13 @@ export default function Image({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative overflow-hidden', classNames.wrapper)} {...props}>
|
||||
<div className={cn('relative overflow-hidden rounded-xl', classNames.wrapper)} {...props}>
|
||||
{/* Spacer: transparent image to maintain dimensions when image is loading */}
|
||||
{isLoading && dim?.width && dim?.height && (
|
||||
<img
|
||||
src={`data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='${dim.width}' height='${dim.height}'%3E%3C/svg%3E`}
|
||||
className={cn(
|
||||
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
|
||||
'object-cover transition-opacity pointer-events-none w-full h-full',
|
||||
className
|
||||
)}
|
||||
alt=""
|
||||
@@ -91,7 +91,7 @@ export default function Image({
|
||||
<ThumbHashPlaceholder
|
||||
thumbHash={thumbHash}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity rounded-xl',
|
||||
'w-full h-full transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
@@ -99,14 +99,14 @@ export default function Image({
|
||||
<BlurHashCanvas
|
||||
blurHash={blurHash}
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity rounded-xl',
|
||||
'w-full h-full transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton
|
||||
className={cn(
|
||||
'w-full h-full transition-opacity rounded-xl',
|
||||
'w-full h-full transition-opacity',
|
||||
isLoading ? 'opacity-100' : 'opacity-0',
|
||||
classNames.skeleton
|
||||
)}
|
||||
@@ -124,7 +124,7 @@ export default function Image({
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className={cn(
|
||||
'object-cover rounded-xl transition-opacity pointer-events-none w-full h-full',
|
||||
'object-cover transition-opacity pointer-events-none w-full h-full',
|
||||
isLoading ? 'opacity-0 absolute inset-0' : '',
|
||||
className
|
||||
)}
|
||||
@@ -137,7 +137,7 @@ export default function Image({
|
||||
alt={alt}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn('object-cover rounded-xl w-full h-full transition-opacity', className)}
|
||||
className={cn('object-cover w-full h-full transition-opacity', className)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -94,9 +94,9 @@ export default function ImageGallery({
|
||||
<ImageWithLightbox
|
||||
key={i}
|
||||
image={image}
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
|
||||
classNames={{
|
||||
wrapper: cn('w-fit max-w-full', className)
|
||||
wrapper: cn('w-fit max-w-full border', className)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -107,10 +107,10 @@ export default function ImageGallery({
|
||||
imageContent = (
|
||||
<Image
|
||||
key={0}
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain border"
|
||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain"
|
||||
classNames={{
|
||||
errorPlaceholder: 'aspect-square h-[30vh]',
|
||||
wrapper: 'cursor-zoom-in'
|
||||
wrapper: 'cursor-zoom-in border'
|
||||
}}
|
||||
image={displayImages[0]}
|
||||
onClick={(e) => handlePhotoClick(e, 0)}
|
||||
@@ -122,8 +122,8 @@ export default function ImageGallery({
|
||||
{displayImages.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="aspect-square w-full border"
|
||||
classNames={{ wrapper: 'cursor-zoom-in' }}
|
||||
className="aspect-square w-full"
|
||||
classNames={{ wrapper: 'cursor-zoom-in border' }}
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
@@ -136,8 +136,8 @@ export default function ImageGallery({
|
||||
{displayImages.map((image, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
className="aspect-square w-full border"
|
||||
classNames={{ wrapper: 'cursor-zoom-in' }}
|
||||
className="aspect-square w-full"
|
||||
classNames={{ wrapper: 'cursor-zoom-in border' }}
|
||||
image={image}
|
||||
onClick={(e) => handlePhotoClick(e, i)}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function ImageWithLightbox({
|
||||
key={0}
|
||||
className={className}
|
||||
classNames={{
|
||||
wrapper: cn('rounded-lg border cursor-zoom-in', classNames.wrapper),
|
||||
wrapper: cn('border cursor-zoom-in', classNames.wrapper),
|
||||
errorPlaceholder: 'aspect-square h-[30vh]',
|
||||
skeleton: classNames.skeleton
|
||||
}}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function FollowPack({ event, className }: { event: Event; classNa
|
||||
{image && (
|
||||
<Image
|
||||
image={{ url: image, pubkey: event.pubkey }}
|
||||
className="w-24 h-20 object-cover rounded-lg"
|
||||
className="w-24 h-20 object-cover"
|
||||
classNames={{
|
||||
wrapper: 'w-24 h-20 flex-shrink-0',
|
||||
errorPlaceholder: 'w-24 h-20'
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function LongFormArticlePreview({
|
||||
{metadata.image && autoLoadMedia && (
|
||||
<Image
|
||||
image={{ url: metadata.image, pubkey: event.pubkey }}
|
||||
className="rounded-lg aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
|
||||
className="aspect-[4/3] xl:aspect-video object-cover bg-foreground h-44"
|
||||
hideIfError
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,18 +10,12 @@ import RepostList from '../RepostList'
|
||||
import ZapList from '../ZapList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
|
||||
export default function NoteInteractions({
|
||||
pageIndex,
|
||||
event
|
||||
}: {
|
||||
pageIndex?: number
|
||||
event: Event
|
||||
}) {
|
||||
export default function NoteInteractions({ event }: { event: Event }) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} stuff={event} />
|
||||
list = <ReplyNoteList stuff={event} />
|
||||
break
|
||||
case 'quotes':
|
||||
list = <QuoteList stuff={event} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NewNotesButton from '@/components/NewNotesButton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
||||
import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
@@ -7,9 +8,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { TFeedSubRequest } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
@@ -76,11 +77,9 @@ const NoteList = forwardRef<
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const { addReplies } = useReply()
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [initialLoading, setInitialLoading] = useState(false)
|
||||
const [filtering, setFiltering] = useState(false)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [filteredNotes, setFilteredNotes] = useState<
|
||||
@@ -88,9 +87,7 @@ const NoteList = forwardRef<
|
||||
>([])
|
||||
const [filteredNewEvents, setFilteredNewEvents] = useState<Event[]>([])
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const topRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const shouldHideEvent = useCallback(
|
||||
@@ -218,10 +215,6 @@ const NoteList = forwardRef<
|
||||
processEvents().finally(() => setFiltering(false))
|
||||
}, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam])
|
||||
|
||||
const slicedNotes = useMemo(() => {
|
||||
return filteredNotes.slice(0, showCount)
|
||||
}, [filteredNotes, showCount])
|
||||
|
||||
useEffect(() => {
|
||||
const processNewEvents = async () => {
|
||||
const keySet = new Set<string>()
|
||||
@@ -273,14 +266,11 @@ const NoteList = forwardRef<
|
||||
if (!subRequests.length) return
|
||||
|
||||
async function init() {
|
||||
setLoading(true)
|
||||
setInitialLoading(true)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
setHasMore(true)
|
||||
|
||||
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
|
||||
setLoading(false)
|
||||
setHasMore(false)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -305,12 +295,9 @@ const NoteList = forwardRef<
|
||||
if (events.length > 0) {
|
||||
setEvents(events)
|
||||
}
|
||||
if (areAlgoRelays) {
|
||||
setHasMore(false)
|
||||
}
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
addReplies(events)
|
||||
threadService.addRepliesToThread(events)
|
||||
setInitialLoading(false)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
@@ -323,7 +310,7 @@ const NoteList = forwardRef<
|
||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||
)
|
||||
}
|
||||
addReplies([event])
|
||||
threadService.addRepliesToThread([event])
|
||||
},
|
||||
onClose: (url, reason) => {
|
||||
if (!showRelayCloseReason) return
|
||||
@@ -358,55 +345,26 @@ const NoteList = forwardRef<
|
||||
}
|
||||
}, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 0.1
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (!timelineKey || areAlgoRelays) return false
|
||||
const newEvents = await client.loadMoreTimeline(
|
||||
timelineKey,
|
||||
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
|
||||
LIMIT
|
||||
)
|
||||
if (newEvents.length === 0) {
|
||||
return false
|
||||
}
|
||||
setEvents((oldEvents) => [...oldEvents, ...newEvents])
|
||||
return true
|
||||
}, [timelineKey, events, areAlgoRelays])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (showCount < events.length) {
|
||||
setShowCount((prev) => prev + SHOW_COUNT)
|
||||
// preload more
|
||||
if (events.length - showCount > LIMIT / 2) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!timelineKey || loading || !hasMore) return
|
||||
setLoading(true)
|
||||
const newEvents = await client.loadMoreTimeline(
|
||||
timelineKey,
|
||||
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
|
||||
LIMIT
|
||||
)
|
||||
setLoading(false)
|
||||
if (newEvents.length === 0) {
|
||||
setHasMore(false)
|
||||
return
|
||||
}
|
||||
setEvents((oldEvents) => [...oldEvents, ...newEvents])
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
loadMore()
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [loading, hasMore, events, showCount, timelineKey])
|
||||
const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
|
||||
items: filteredNotes,
|
||||
showCount: SHOW_COUNT,
|
||||
onLoadMore: handleLoadMore,
|
||||
initialLoading
|
||||
})
|
||||
|
||||
const showNewEvents = () => {
|
||||
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||
@@ -419,7 +377,7 @@ const NoteList = forwardRef<
|
||||
const list = (
|
||||
<div className="min-h-screen">
|
||||
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
||||
{slicedNotes.map(({ key, event, reposters }) => (
|
||||
{visibleItems.map(({ key, event, reposters }) => (
|
||||
<NoteCard
|
||||
key={key}
|
||||
className="w-full"
|
||||
@@ -428,10 +386,9 @@ const NoteList = forwardRef<
|
||||
reposters={reposters}
|
||||
/>
|
||||
))}
|
||||
{hasMore || showCount < events.length || loading || filtering ? (
|
||||
<div ref={bottomRef}>
|
||||
<NoteCardLoadingSkeleton />
|
||||
</div>
|
||||
<div ref={bottomRef} />
|
||||
{shouldShowLoadingIndicator || filtering || initialLoading ? (
|
||||
<NoteCardLoadingSkeleton />
|
||||
) : events.length ? (
|
||||
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
|
||||
) : (
|
||||
|
||||
@@ -40,8 +40,8 @@ export function ReactionNotification({
|
||||
<Image
|
||||
image={{ url: emojiUrl, pubkey: notification.pubkey }}
|
||||
alt={emojiName}
|
||||
className="w-6 h-6 rounded-md"
|
||||
classNames={{ errorPlaceholder: 'bg-transparent' }}
|
||||
className="w-6 h-6"
|
||||
classNames={{ errorPlaceholder: 'bg-transparent', wrapper: 'rounded-md' }}
|
||||
errorPlaceholder={<Heart size={24} className="text-red-400" />}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNotification } from '@/providers/NotificationProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||
import client from '@/services/client.service'
|
||||
import stuffStatsService from '@/services/stuff-stats.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { TNotificationType } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||
@@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
const { pubkey } = useNostr()
|
||||
const { getNotificationsSeenAt } = useNotification()
|
||||
const { notificationListStyle } = useUserPreferences()
|
||||
const { addReplies } = useReply()
|
||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||
const [lastReadTime, setLastReadTime] = useState(0)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
@@ -143,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => {
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||
addReplies(events)
|
||||
threadService.addRepliesToThread(events)
|
||||
stuffStatsService.updateStuffStatsByEvents(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
handleNewEvent(event)
|
||||
addReplies([event])
|
||||
threadService.addRepliesToThread([event])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from '@/lib/draft-event'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import postEditorCache from '@/services/post-editor-cache.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { TPollCreateData } from '@/types'
|
||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
@@ -42,7 +42,6 @@ export default function PostContent({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, publish, checkLogin } = useNostr()
|
||||
const { addReplies } = useReply()
|
||||
const [text, setText] = useState('')
|
||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||
const [posting, setPosting] = useState(false)
|
||||
@@ -157,7 +156,7 @@ export default function PostContent({
|
||||
})
|
||||
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
||||
deleteDraftEventCache(draftEvent)
|
||||
addReplies([newEvent])
|
||||
threadService.addRepliesToThread([newEvent])
|
||||
toast.success(t('Post successful'), { duration: 2000 })
|
||||
close()
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Image from '../Image'
|
||||
|
||||
@@ -27,7 +26,10 @@ export default function ProfileBanner({
|
||||
<Image
|
||||
image={{ url: bannerUrl, pubkey }}
|
||||
alt={`${pubkey} banner`}
|
||||
className={cn('rounded-none', className)}
|
||||
className={className}
|
||||
classNames={{
|
||||
wrapper: 'rounded-none'
|
||||
}}
|
||||
errorPlaceholder={defaultBanner}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -36,9 +36,9 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
||||
<div className="px-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex gap-2 items-center truncate">
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<RelayIcon url={url} className="w-8 h-8" />
|
||||
<div className="text-2xl font-semibold truncate select-text">
|
||||
<div className="text-2xl font-semibold truncate select-text flex-1 w-0">
|
||||
{relayInfo.name || relayInfo.shortUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,16 @@ export default function RelaySimpleInfo({
|
||||
</div>
|
||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
||||
</div>
|
||||
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
|
||||
{!!relayInfo?.description && (
|
||||
<div
|
||||
className="line-clamp-3 break-words whitespace-pre-wrap"
|
||||
style={{
|
||||
overflowWrap: 'anywhere'
|
||||
}}
|
||||
>
|
||||
{relayInfo.description}
|
||||
</div>
|
||||
)}
|
||||
{!!users?.length && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground">{t('Favorited by')} </div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { isMentioningMutedUsers } from '@/lib/event'
|
||||
import { useThread } from '@/hooks/useThread'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -40,7 +42,10 @@ export default function ReplyNote({
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { push } = useSecondaryPage()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const eventKey = useMemo(() => getEventKey(event), [event])
|
||||
const replies = useThread(eventKey)
|
||||
const [showMuted, setShowMuted] = useState(false)
|
||||
const show = useMemo(() => {
|
||||
if (showMuted) {
|
||||
@@ -54,16 +59,35 @@ export default function ReplyNote({
|
||||
}
|
||||
return true
|
||||
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
|
||||
const hasReplies = useMemo(() => {
|
||||
if (!replies || replies.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const reply of replies) {
|
||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||
continue
|
||||
}
|
||||
if (mutePubkeySet.has(reply.pubkey)) {
|
||||
continue
|
||||
}
|
||||
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
}, [replies])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pb-3 transition-colors duration-500 clickable',
|
||||
'relative pb-3 transition-colors duration-500 clickable',
|
||||
highlight ? 'bg-primary/40' : '',
|
||||
className
|
||||
)}
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
{hasReplies && <div className="absolute left-[34px] top-14 bottom-0 border-l z-20" />}
|
||||
<Collapsible>
|
||||
<div className="flex space-x-2 items-start px-4 pt-3">
|
||||
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { generateBech32IdFromETag } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
@@ -16,7 +16,7 @@ import ReplyNote from '../ReplyNote'
|
||||
export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { repliesMap } = useReply()
|
||||
const allThreads = useAllDescendantThreads(parentKey)
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
@@ -27,7 +27,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
|
||||
let parentKeys = [parentKey]
|
||||
while (parentKeys.length > 0) {
|
||||
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
||||
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
|
||||
events.forEach((evt) => {
|
||||
const key = getEventKey(evt)
|
||||
if (replyKeySet.has(key)) return
|
||||
@@ -35,11 +35,11 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
|
||||
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
||||
const replyKey = getEventKey(evt)
|
||||
const repliesForThisReply = repliesMap.get(replyKey)
|
||||
const repliesForThisReply = allThreads.get(replyKey)
|
||||
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
||||
if (
|
||||
!repliesForThisReply ||
|
||||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
|
||||
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
||||
}, [
|
||||
parentKey,
|
||||
repliesMap,
|
||||
allThreads,
|
||||
mutePubkeySet,
|
||||
hideContentMentioningMutedUsers,
|
||||
hideUntrustedInteractions
|
||||
@@ -81,7 +81,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
}, 1500)
|
||||
}, [])
|
||||
|
||||
if (replies.length === 0) return <div className="border-b w-full" />
|
||||
if (replies.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -91,11 +91,16 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(!isExpanded)
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable',
|
||||
!isExpanded && 'border-b'
|
||||
)}
|
||||
className="relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable"
|
||||
>
|
||||
<div
|
||||
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
|
||||
style={{
|
||||
background: isExpanded
|
||||
? 'currentColor'
|
||||
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
|
||||
}}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-3.5" />
|
||||
@@ -125,14 +130,14 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||
<div
|
||||
ref={(el) => (replyRefs.current[currentReplyKey] = el)}
|
||||
key={currentReplyKey}
|
||||
className="scroll-mt-12 flex"
|
||||
className="scroll-mt-12 flex relative"
|
||||
>
|
||||
<div className="w-3 flex-shrink-0 bg-border" />
|
||||
<div className="absolute left-[34px] top-0 h-8 w-4 rounded-bl-lg border-l border-b z-20" />
|
||||
{index < replies.length - 1 && (
|
||||
<div className="absolute left-[34px] top-0 bottom-0 border-l z-20" />
|
||||
)}
|
||||
<ReplyNote
|
||||
className={cn(
|
||||
'border-l flex-1 w-0 border-t',
|
||||
index === replies.length - 1 && 'border-b'
|
||||
)}
|
||||
className="flex-1 w-0 pl-10"
|
||||
event={reply}
|
||||
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
|
||||
onClickParent={() => {
|
||||
|
||||
@@ -1,53 +1,34 @@
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import {
|
||||
getEventKey,
|
||||
getReplaceableCoordinateFromEvent,
|
||||
getRootTag,
|
||||
isMentioningMutedUsers,
|
||||
isProtectedEvent,
|
||||
isReplaceableEvent
|
||||
} from '@/lib/event'
|
||||
import { generateBech32IdFromETag } from '@/lib/tag'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import threadService from '@/services/thread.service'
|
||||
import { Event as NEvent } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoadingBar } from '../LoadingBar'
|
||||
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
||||
import SubReplies from './SubReplies'
|
||||
|
||||
type TRootInfo =
|
||||
| { type: 'E'; id: string; pubkey: string }
|
||||
| { type: 'A'; id: string; pubkey: string; relay?: string }
|
||||
| { type: 'I'; id: string }
|
||||
|
||||
const LIMIT = 100
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function ReplyNoteList({
|
||||
stuff,
|
||||
index
|
||||
}: {
|
||||
stuff: NEvent | string
|
||||
index?: number
|
||||
}) {
|
||||
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { currentIndex } = useSecondaryPage()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
||||
const { repliesMap, addReplies } = useReply()
|
||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
||||
const { stuffKey } = useStuff(stuff)
|
||||
const allThreads = useAllDescendantThreads(stuffKey)
|
||||
const [initialLoading, setInitialLoading] = useState(false)
|
||||
|
||||
const replies = useMemo(() => {
|
||||
const replyKeySet = new Set<string>()
|
||||
const replyEvents = (repliesMap.get(stuffKey)?.events || []).filter((evt) => {
|
||||
const thread = allThreads.get(stuffKey) || []
|
||||
const replyEvents = thread.filter((evt) => {
|
||||
const key = getEventKey(evt)
|
||||
if (replyKeySet.has(key)) return false
|
||||
if (mutePubkeySet.has(evt.pubkey)) return false
|
||||
@@ -56,11 +37,11 @@ export default function ReplyNoteList({
|
||||
}
|
||||
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
||||
const replyKey = getEventKey(evt)
|
||||
const repliesForThisReply = repliesMap.get(replyKey)
|
||||
const repliesForThisReply = allThreads.get(replyKey)
|
||||
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
||||
if (
|
||||
!repliesForThisReply ||
|
||||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
|
||||
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -69,226 +50,69 @@ export default function ReplyNoteList({
|
||||
replyKeySet.add(key)
|
||||
return true
|
||||
})
|
||||
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
||||
return replyEvents.sort((a, b) => b.created_at - a.created_at)
|
||||
}, [
|
||||
stuffKey,
|
||||
repliesMap,
|
||||
allThreads,
|
||||
mutePubkeySet,
|
||||
hideContentMentioningMutedUsers,
|
||||
hideUntrustedInteractions
|
||||
hideUntrustedInteractions,
|
||||
isUserTrusted
|
||||
])
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [until, setUntil] = useState<number | undefined>(undefined)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Initial subscription
|
||||
useEffect(() => {
|
||||
const fetchRootEvent = async () => {
|
||||
if (!event && !externalContent) return
|
||||
|
||||
let root: TRootInfo = event
|
||||
? isReplaceableEvent(event.kind)
|
||||
? {
|
||||
type: 'A',
|
||||
id: getReplaceableCoordinateFromEvent(event),
|
||||
pubkey: event.pubkey,
|
||||
relay: client.getEventHint(event.id)
|
||||
}
|
||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
||||
: { type: 'I', id: externalContent! }
|
||||
|
||||
const rootTag = getRootTag(event)
|
||||
if (rootTag?.type === 'e') {
|
||||
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
|
||||
if (rootEventHexId && rootEventPubkey) {
|
||||
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
|
||||
} else {
|
||||
const rootEventId = generateBech32IdFromETag(rootTag.tag)
|
||||
if (rootEventId) {
|
||||
const rootEvent = await client.fetchEvent(rootEventId)
|
||||
if (rootEvent) {
|
||||
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (rootTag?.type === 'a') {
|
||||
const [, coordinate, relay] = rootTag.tag
|
||||
const [, pubkey] = coordinate.split(':')
|
||||
root = { type: 'A', id: coordinate, pubkey, relay }
|
||||
} else if (rootTag?.type === 'i') {
|
||||
root = { type: 'I', id: rootTag.tag[1] }
|
||||
}
|
||||
setRootInfo(root)
|
||||
}
|
||||
fetchRootEvent()
|
||||
}, [event])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !rootInfo || currentIndex !== index) return
|
||||
|
||||
const init = async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
let relayUrls: string[] = []
|
||||
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
|
||||
if (rootPubkey) {
|
||||
const relayList = await client.fetchRelayList(rootPubkey)
|
||||
relayUrls = relayList.read
|
||||
}
|
||||
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
|
||||
// If current event is protected, we can assume its replies are also protected and stored on the same relays
|
||||
if (event && isProtectedEvent(event)) {
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
relayUrls.concat(...seenOn)
|
||||
}
|
||||
|
||||
const filters: (Omit<Filter, 'since' | 'until'> & {
|
||||
limit: number
|
||||
})[] = []
|
||||
if (rootInfo.type === 'E') {
|
||||
filters.push({
|
||||
'#e': [rootInfo.id],
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit: LIMIT
|
||||
})
|
||||
if (event?.kind !== kinds.ShortTextNote) {
|
||||
filters.push({
|
||||
'#E': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
limit: LIMIT
|
||||
})
|
||||
}
|
||||
} else if (rootInfo.type === 'A') {
|
||||
filters.push(
|
||||
{
|
||||
'#a': [rootInfo.id],
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
'#A': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
limit: LIMIT
|
||||
}
|
||||
)
|
||||
if (rootInfo.relay) {
|
||||
relayUrls.push(rootInfo.relay)
|
||||
}
|
||||
} else {
|
||||
filters.push({
|
||||
'#I': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
limit: LIMIT
|
||||
})
|
||||
}
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
filters.map((filter) => ({
|
||||
urls: relayUrls.slice(0, 8),
|
||||
filter
|
||||
})),
|
||||
{
|
||||
onEvents: (evts, eosed) => {
|
||||
if (evts.length > 0) {
|
||||
addReplies(evts)
|
||||
}
|
||||
if (eosed) {
|
||||
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
onNew: (evt) => {
|
||||
addReplies([evt])
|
||||
}
|
||||
}
|
||||
)
|
||||
setTimelineKey(timelineKey)
|
||||
return closer
|
||||
} catch {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
const loadInitial = async () => {
|
||||
setInitialLoading(true)
|
||||
await threadService.subscribe(stuff, LIMIT)
|
||||
setInitialLoading(false)
|
||||
}
|
||||
|
||||
const promise = init()
|
||||
return () => {
|
||||
promise.then((closer) => closer?.())
|
||||
}
|
||||
}, [rootInfo, currentIndex, index])
|
||||
|
||||
useEffect(() => {
|
||||
if (replies.length === 0) {
|
||||
loadMore()
|
||||
}
|
||||
}, [replies])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 0.1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && showCount < replies.length) {
|
||||
setShowCount((prev) => prev + SHOW_COUNT)
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
loadInitial()
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
threadService.unsubscribe(stuff)
|
||||
}
|
||||
}, [replies, showCount])
|
||||
}, [stuff])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loading || !until || !timelineKey) return
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
return await threadService.loadMore(stuff, LIMIT)
|
||||
}, [stuff])
|
||||
|
||||
setLoading(true)
|
||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
addReplies(events)
|
||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||
setLoading(false)
|
||||
}, [loading, until, timelineKey])
|
||||
const { visibleItems, loading, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
|
||||
items: replies,
|
||||
showCount: SHOW_COUNT,
|
||||
onLoadMore: handleLoadMore,
|
||||
initialLoading
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{loading && <LoadingBar />}
|
||||
{!loading && until && (!event || until > event.created_at) && (
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{t('load more older replies')}
|
||||
</div>
|
||||
)}
|
||||
{(loading || initialLoading) && <LoadingBar />}
|
||||
<div>
|
||||
{replies.slice(0, showCount).map((reply) => {
|
||||
const key = getEventKey(reply)
|
||||
return (
|
||||
<div key={key}>
|
||||
<ReplyNote event={reply} />
|
||||
<SubReplies parentKey={key} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{visibleItems.map((reply) => (
|
||||
<Item key={reply.id} reply={reply} />
|
||||
))}
|
||||
</div>
|
||||
{!loading && (
|
||||
<div ref={bottomRef} />
|
||||
{shouldShowLoadingIndicator ? (
|
||||
<ReplyNoteSkeleton />
|
||||
) : (
|
||||
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
|
||||
{replies.length > 0 ? t('no more replies') : t('no replies')}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
{loading && <ReplyNoteSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ reply }: { reply: NEvent }) {
|
||||
const key = useMemo(() => getEventKey(reply), [reply])
|
||||
|
||||
return (
|
||||
<div className="relative border-b">
|
||||
<ReplyNote event={reply} />
|
||||
<SubReplies parentKey={key} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useStuff } from '@/hooks/useStuff'
|
||||
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey, checkLogin } = useNostr()
|
||||
const { event, stuffKey } = useStuff(stuff)
|
||||
const { repliesMap } = useReply()
|
||||
const allThreads = useAllDescendantThreads(stuffKey)
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { replyCount, hasReplied } = useMemo(() => {
|
||||
const hasReplied = pubkey
|
||||
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
|
||||
? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
|
||||
: false
|
||||
|
||||
let replyCount = 0
|
||||
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
|
||||
const replies = [...(allThreads.get(stuffKey) ?? [])]
|
||||
while (replies.length > 0) {
|
||||
const reply = replies.pop()
|
||||
if (!reply) break
|
||||
|
||||
const replyKey = getEventKey(reply)
|
||||
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
|
||||
const nestedReplies = allThreads.get(replyKey) ?? []
|
||||
replies.push(...nestedReplies)
|
||||
|
||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
}
|
||||
|
||||
return { replyCount, hasReplied }
|
||||
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
|
||||
}, [allThreads, event, stuffKey, hideUntrustedInteractions])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,9 +12,9 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
|
||||
import { TFeedSubRequest } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
|
||||
const { pinnedPubkeySet } = usePinnedUsers()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const { addReplies } = useReply()
|
||||
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setHasMore(events.length > 0)
|
||||
addReplies(events)
|
||||
threadService.addRepliesToThread(events)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
setNewEvents((oldEvents) =>
|
||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||
)
|
||||
addReplies([event])
|
||||
threadService.addRepliesToThread([event])
|
||||
},
|
||||
onClose: (url, reason) => {
|
||||
if (!showRelayCloseReason) return
|
||||
|
||||
@@ -68,9 +68,9 @@ export default function WebPreview({
|
||||
{image && (
|
||||
<Image
|
||||
image={{ url: image }}
|
||||
className="aspect-[4/3] xl:aspect-video bg-foreground h-44 rounded-none border-r"
|
||||
className="aspect-[4/3] xl:aspect-video bg-foreground h-44"
|
||||
classNames={{
|
||||
skeleton: 'rounded-none border-r'
|
||||
wrapper: 'rounded-none border-r'
|
||||
}}
|
||||
hideIfError
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { TMailboxRelay } from './types'
|
||||
|
||||
export const JUMBLE_API_BASE_URL = 'https://api.jumble.social'
|
||||
|
||||
@@ -63,22 +62,20 @@ export const ApplicationDataKey = {
|
||||
|
||||
export const BIG_RELAY_URLS = [
|
||||
'wss://relay.damus.io/',
|
||||
'wss://relay.nostr.band/',
|
||||
'wss://nos.lol/',
|
||||
'wss://relay.primal.net/',
|
||||
'wss://nos.lol/'
|
||||
'wss://offchain.pub/'
|
||||
]
|
||||
|
||||
export const SEARCHABLE_RELAY_URLS = ['wss://relay.nostr.band/', 'wss://search.nos.today/']
|
||||
export const SEARCHABLE_RELAY_URLS = [
|
||||
'wss://search.nos.today/',
|
||||
'wss://relay.ditto.pub/',
|
||||
'wss://relay.nostrcheck.me/',
|
||||
'wss://relay.nostr.band/'
|
||||
]
|
||||
|
||||
export const TRENDING_NOTES_RELAY_URLS = ['wss://trending.relays.land/']
|
||||
|
||||
export const NEW_USER_RELAY_LIST: TMailboxRelay[] = [
|
||||
{ url: 'wss://nos.lol/', scope: 'both' },
|
||||
{ url: 'wss://offchain.pub/', scope: 'both' },
|
||||
{ url: 'wss://relay.damus.io/', scope: 'both' },
|
||||
{ url: 'wss://nostr.mom/', scope: 'both' }
|
||||
]
|
||||
|
||||
export const GROUP_METADATA_EVENT_KIND = 39000
|
||||
|
||||
export const ExtendedKind = {
|
||||
|
||||
@@ -5,5 +5,6 @@ export * from './useFetchProfile'
|
||||
export * from './useFetchRelayInfo'
|
||||
export * from './useFetchRelayInfos'
|
||||
export * from './useFetchRelayList'
|
||||
export * from './useInfiniteScroll'
|
||||
export * from './useSearchProfiles'
|
||||
export * from './useTranslatedEvent'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useReply } from '@/providers/ReplyProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -7,7 +6,6 @@ import { useEffect, useState } from 'react'
|
||||
export function useFetchEvent(eventId?: string) {
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const [isFetching, setIsFetching] = useState(true)
|
||||
const { addReplies } = useReply()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [event, setEvent] = useState<Event | undefined>(undefined)
|
||||
|
||||
@@ -23,7 +21,6 @@ export function useFetchEvent(eventId?: string) {
|
||||
const event = await client.fetchEvent(eventId)
|
||||
if (event && !isEventDeleted(event)) {
|
||||
setEvent(event)
|
||||
addReplies([event])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
src/hooks/useInfiniteScroll.tsx
Normal file
119
src/hooks/useInfiniteScroll.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export interface UseInfiniteScrollOptions<T> {
|
||||
/**
|
||||
* The initial data items
|
||||
*/
|
||||
items: T[]
|
||||
/**
|
||||
* Whether to initially show all items or use pagination
|
||||
* @default false
|
||||
*/
|
||||
showAllInitially?: boolean
|
||||
/**
|
||||
* Number of items to show initially and load per batch
|
||||
* @default 10
|
||||
*/
|
||||
showCount?: number
|
||||
/**
|
||||
* Initial loading state, which can be used to prevent loading more data until initial load is complete
|
||||
*/
|
||||
initialLoading?: boolean
|
||||
/**
|
||||
* The function to load more data
|
||||
* Returns true if there are more items to load, false otherwise
|
||||
*/
|
||||
onLoadMore: () => Promise<boolean>
|
||||
/**
|
||||
* IntersectionObserver options
|
||||
*/
|
||||
observerOptions?: IntersectionObserverInit
|
||||
}
|
||||
|
||||
export function useInfiniteScroll<T>({
|
||||
items,
|
||||
showAllInitially = false,
|
||||
showCount: initialShowCount = 10,
|
||||
onLoadMore,
|
||||
initialLoading = false,
|
||||
observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '100px',
|
||||
threshold: 0
|
||||
}
|
||||
}: UseInfiniteScrollOptions<T>) {
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [showCount, setShowCount] = useState(showAllInitially ? Infinity : initialShowCount)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const stateRef = useRef({
|
||||
loading,
|
||||
hasMore,
|
||||
showCount,
|
||||
itemsLength: items.length,
|
||||
initialLoading
|
||||
})
|
||||
|
||||
stateRef.current = {
|
||||
loading,
|
||||
hasMore,
|
||||
showCount,
|
||||
itemsLength: items.length,
|
||||
initialLoading
|
||||
}
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current
|
||||
|
||||
if (initialLoading || loading) return
|
||||
|
||||
// If there are more items to show, increase showCount first
|
||||
if (showCount < itemsLength) {
|
||||
setShowCount((prev) => prev + initialShowCount)
|
||||
// Only fetch more data when remaining items are running low
|
||||
if (itemsLength - showCount > initialShowCount * 2) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasMore) return
|
||||
setLoading(true)
|
||||
const newHasMore = await onLoadMore()
|
||||
setHasMore(newHasMore)
|
||||
setLoading(false)
|
||||
}, [onLoadMore, initialShowCount])
|
||||
|
||||
// IntersectionObserver setup
|
||||
useEffect(() => {
|
||||
const currentBottomRef = bottomRef.current
|
||||
if (!currentBottomRef) return
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
}, observerOptions)
|
||||
|
||||
observer.observe(currentBottomRef)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [loadMore, observerOptions])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
return showAllInitially ? items : items.slice(0, showCount)
|
||||
}, [items, showAllInitially, showCount])
|
||||
|
||||
const shouldShowLoadingIndicator = hasMore || showCount < items.length || loading
|
||||
|
||||
return {
|
||||
visibleItems,
|
||||
loading,
|
||||
hasMore,
|
||||
shouldShowLoadingIndicator,
|
||||
bottomRef,
|
||||
setHasMore,
|
||||
setLoading
|
||||
}
|
||||
}
|
||||
16
src/hooks/useThread.tsx
Normal file
16
src/hooks/useThread.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import threadService from '@/services/thread.service'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
export function useThread(stuffKey: string) {
|
||||
return useSyncExternalStore(
|
||||
(cb) => threadService.listenThread(stuffKey, cb),
|
||||
() => threadService.getThread(stuffKey)
|
||||
)
|
||||
}
|
||||
|
||||
export function useAllDescendantThreads(stuffKey: string) {
|
||||
return useSyncExternalStore(
|
||||
(cb) => threadService.listenAllDescendantThreads(stuffKey, cb),
|
||||
() => threadService.getAllDescendantThreads(stuffKey)
|
||||
)
|
||||
}
|
||||
@@ -633,6 +633,7 @@ export default {
|
||||
'أضف كلمة مرور لتشفير مفتاحك الخاص في هذا المتصفح. هذا اختياري لكنه موصى به بشدة لأمان أفضل.',
|
||||
'Create a password (or skip)': 'أنشئ كلمة مرور (أو تخطى)',
|
||||
'Enter your password again': 'أدخل كلمة المرور مرة أخرى',
|
||||
'Complete Signup': 'إكمال التسجيل'
|
||||
'Complete Signup': 'إكمال التسجيل',
|
||||
Recommended: 'موصى به'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +654,7 @@ export default {
|
||||
'Fügen Sie ein Passwort hinzu, um Ihren privaten Schlüssel in diesem Browser zu verschlüsseln. Dies ist optional, aber für bessere Sicherheit dringend empfohlen.',
|
||||
'Create a password (or skip)': 'Erstellen Sie ein Passwort (oder überspringen)',
|
||||
'Enter your password again': 'Geben Sie Ihr Passwort erneut ein',
|
||||
'Complete Signup': 'Registrierung abschließen'
|
||||
'Complete Signup': 'Registrierung abschließen',
|
||||
Recommended: 'Empfohlen'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +638,7 @@ export default {
|
||||
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.',
|
||||
'Create a password (or skip)': 'Create a password (or skip)',
|
||||
'Enter your password again': 'Enter your password again',
|
||||
'Complete Signup': 'Complete Signup'
|
||||
'Complete Signup': 'Complete Signup',
|
||||
Recommended: 'Recommended'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +648,7 @@ export default {
|
||||
'Añade una contraseña para cifrar tu clave privada en este navegador. Esto es opcional pero muy recomendado para mayor seguridad.',
|
||||
'Create a password (or skip)': 'Crear una contraseña (o saltar)',
|
||||
'Enter your password again': 'Ingresa tu contraseña nuevamente',
|
||||
'Complete Signup': 'Completar registro'
|
||||
'Complete Signup': 'Completar registro',
|
||||
Recommended: 'Recomendado'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +643,7 @@ export default {
|
||||
'یک رمز عبور برای رمزگذاری کلید خصوصی خود در این مرورگر اضافه کنید. این اختیاری است اما برای امنیت بهتر به شدت توصیه میشود.',
|
||||
'Create a password (or skip)': 'یک رمز عبور ایجاد کنید (یا رد کنید)',
|
||||
'Enter your password again': 'رمز عبور خود را دوباره وارد کنید',
|
||||
'Complete Signup': 'تکمیل ثبتنام'
|
||||
'Complete Signup': 'تکمیل ثبتنام',
|
||||
Recommended: 'توصیه شده'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,6 +651,7 @@ export default {
|
||||
"Ajoutez un mot de passe pour chiffrer votre clé privée dans ce navigateur. C'est facultatif mais fortement recommandé pour une meilleure sécurité.",
|
||||
'Create a password (or skip)': 'Créez un mot de passe (ou ignorez)',
|
||||
'Enter your password again': 'Entrez à nouveau votre mot de passe',
|
||||
'Complete Signup': "Terminer l'inscription"
|
||||
'Complete Signup': "Terminer l'inscription",
|
||||
Recommended: 'Recommandé'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ export default {
|
||||
'इस ब्राउज़र में अपनी निजी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड जोड़ें। यह वैकल्पिक है लेकिन बेहतर सुरक्षा के लिए दृढ़ता से अनुशंसित है।',
|
||||
'Create a password (or skip)': 'एक पासवर्ड बनाएं (या छोड़ें)',
|
||||
'Enter your password again': 'अपना पासवर्ड फिर से दर्ज करें',
|
||||
'Complete Signup': 'साइनअप पूर्ण करें'
|
||||
'Complete Signup': 'साइनअप पूर्ण करें',
|
||||
Recommended: 'अनुशंसित'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,6 +636,7 @@ export default {
|
||||
'Adj hozzá jelszót a privát kulcsod titkosításához ebben a böngészőben. Ez opcionális, de erősen ajánlott a jobb biztonság érdekében.',
|
||||
'Create a password (or skip)': 'Hozz létre jelszót (vagy hagyd ki)',
|
||||
'Enter your password again': 'Add meg újra a jelszavad',
|
||||
'Complete Signup': 'Regisztráció befejezése'
|
||||
'Complete Signup': 'Regisztráció befejezése',
|
||||
Recommended: 'Ajánlott'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +648,7 @@ export default {
|
||||
'Aggiungi una password per crittografare la tua chiave privata in questo browser. È facoltativo ma fortemente consigliato per una migliore sicurezza.',
|
||||
'Create a password (or skip)': 'Crea una password (o salta)',
|
||||
'Enter your password again': 'Inserisci di nuovo la tua password',
|
||||
'Complete Signup': 'Completa registrazione'
|
||||
'Complete Signup': 'Completa registrazione',
|
||||
Recommended: 'Consigliato'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,6 +642,7 @@ export default {
|
||||
'このブラウザで秘密鍵を暗号化するパスワードを追加します。オプションですが、より良いセキュリティのために強くお勧めします。',
|
||||
'Create a password (or skip)': 'パスワードを作成(またはスキップ)',
|
||||
'Enter your password again': 'パスワードをもう一度入力',
|
||||
'Complete Signup': '登録を完了'
|
||||
'Complete Signup': '登録を完了',
|
||||
Recommended: 'おすすめ'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,6 +639,7 @@ export default {
|
||||
'이 브라우저에서 개인 키를 암호화할 비밀번호를 추가합니다. 선택사항이지만 더 나은 보안을 위해 강력히 권장합니다.',
|
||||
'Create a password (or skip)': '비밀번호 생성(또는 건너뛰기)',
|
||||
'Enter your password again': '비밀번호를 다시 입력하세요',
|
||||
'Complete Signup': '가입 완료'
|
||||
'Complete Signup': '가입 완료',
|
||||
Recommended: '추천'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +649,7 @@ export default {
|
||||
'Dodaj hasło, aby zaszyfrować swój klucz prywatny w tej przeglądarce. Jest to opcjonalne, ale zdecydowanie zalecane dla lepszego bezpieczeństwa.',
|
||||
'Create a password (or skip)': 'Utwórz hasło (lub pomiń)',
|
||||
'Enter your password again': 'Wprowadź hasło ponownie',
|
||||
'Complete Signup': 'Zakończ rejestrację'
|
||||
'Complete Signup': 'Zakończ rejestrację',
|
||||
Recommended: 'Polecane'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,12 +629,12 @@ export default {
|
||||
'Passwords do not match': 'As senhas não coincidem',
|
||||
'Finish Signup': 'Concluir cadastro',
|
||||
// New improved signup copy
|
||||
'Create Your Nostr Account': 'Crie sua conta Nostr',
|
||||
'Create Your Nostr Account': 'Criando sua conta Nostr',
|
||||
'Generate your unique private key. This is your digital identity.':
|
||||
'Gere sua chave privada única. Esta é sua identidade digital.',
|
||||
'Critical: Save Your Private Key': 'Crítico: Salve sua chave privada',
|
||||
'Sua chave privada única foi gerada. Ela é sua identidade digital.',
|
||||
'Critical: Save Your Private Key': 'Importante: Salve a sua chave privada.',
|
||||
'Your private key IS your account. There is no password recovery. If you lose it, you lose your account forever. Please save it in a secure location.':
|
||||
'Sua chave privada É sua conta. Não há recuperação de senha. Se você perdê-la, perderá sua conta para sempre. Por favor, salve-a em um local seguro.',
|
||||
'Sua chave privada é a sua conta. Não há recuperação de senha, se você perdê-la, perderá sua conta para sempre. Por favor, salve-a em um local seguro.',
|
||||
'I have safely backed up my private key': 'Fiz backup seguro da minha chave privada',
|
||||
'Secure Your Account': 'Proteja sua conta',
|
||||
'Add an extra layer of protection with a password':
|
||||
@@ -642,8 +642,9 @@ export default {
|
||||
'Password Protection (Recommended)': 'Proteção por senha (recomendado)',
|
||||
'Add a password to encrypt your private key in this browser. This is optional but strongly recommended for better security.':
|
||||
'Adicione uma senha para criptografar sua chave privada neste navegador. Isso é opcional, mas fortemente recomendado para melhor segurança.',
|
||||
'Create a password (or skip)': 'Crie uma senha (ou pule)',
|
||||
'Create a password (or skip)': 'Crie uma senha (opcional)',
|
||||
'Enter your password again': 'Digite sua senha novamente',
|
||||
'Complete Signup': 'Concluir cadastro'
|
||||
'Complete Signup': 'Concluir cadastro',
|
||||
Recommended: 'Recomendado'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,6 +647,7 @@ export default {
|
||||
'Adicione uma palavra-passe para encriptar a sua chave privada neste navegador. Isto é opcional, mas fortemente recomendado para melhor segurança.',
|
||||
'Create a password (or skip)': 'Crie uma palavra-passe (ou ignore)',
|
||||
'Enter your password again': 'Introduza novamente a sua palavra-passe',
|
||||
'Complete Signup': 'Concluir registo'
|
||||
'Complete Signup': 'Concluir registo',
|
||||
Recommended: 'Recomendado'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +648,7 @@ export default {
|
||||
'Добавьте пароль для шифрования вашего приватного ключа в этом браузере. Это необязательно, но настоятельно рекомендуется для лучшей безопасности.',
|
||||
'Create a password (or skip)': 'Создайте пароль (или пропустите)',
|
||||
'Enter your password again': 'Введите пароль еще раз',
|
||||
'Complete Signup': 'Завершить регистрацию'
|
||||
'Complete Signup': 'Завершить регистрацию',
|
||||
Recommended: 'Рекомендуемые'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +633,7 @@ export default {
|
||||
'เพิ่มรหัสผ่านเพื่อเข้ารหัสคีย์ส่วนตัวของคุณในเบราว์เซอร์นี้ เป็นตัวเลือก แต่แนะนำอย่างยิ่งเพื่อความปลอดภัยที่ดีขึ้น',
|
||||
'Create a password (or skip)': 'สร้างรหัสผ่าน (หรือข้าม)',
|
||||
'Enter your password again': 'ป้อนรหัสผ่านของคุณอีกครั้ง',
|
||||
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน'
|
||||
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน',
|
||||
Recommended: 'แนะนำ'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,6 +619,7 @@ export default {
|
||||
'新增密碼以在此瀏覽器中加密你的私鑰。這是可選的,但強烈建議設定以獲得更好的安全性。',
|
||||
'Create a password (or skip)': '建立密碼(或跳過)',
|
||||
'Enter your password again': '再次輸入你的密碼',
|
||||
'Complete Signup': '完成註冊'
|
||||
'Complete Signup': '完成註冊',
|
||||
Recommended: '推薦'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,6 +624,7 @@ export default {
|
||||
'添加密码以在此浏览器中加密你的私钥。这是可选的,但强烈建议设置以获得更好的安全性。',
|
||||
'Create a password (or skip)': '创建密码(或跳过)',
|
||||
'Enter your password again': '再次输入你的密码',
|
||||
'Complete Signup': '完成注册'
|
||||
'Complete Signup': '完成注册',
|
||||
Recommended: '推荐'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Event, kinds, nip19 } from 'nostr-tools'
|
||||
import {
|
||||
getReplaceableCoordinate,
|
||||
getReplaceableCoordinateFromEvent,
|
||||
getRootETag,
|
||||
getRootTag,
|
||||
isProtectedEvent,
|
||||
isReplaceableEvent
|
||||
} from './event'
|
||||
@@ -153,7 +153,7 @@ export async function createShortTextNoteDraftEvent(
|
||||
} = {}
|
||||
): Promise<TDraftEvent> {
|
||||
const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content)
|
||||
const { quoteTags, rootETag, parentETag } = await extractRelatedEventIds(
|
||||
const { quoteTags, rootTag, parentTag } = await extractRelatedEventIds(
|
||||
transformedEmojisContent,
|
||||
options.parentEvent
|
||||
)
|
||||
@@ -170,13 +170,13 @@ export async function createShortTextNoteDraftEvent(
|
||||
// q tags
|
||||
tags.push(...quoteTags)
|
||||
|
||||
// e tags
|
||||
if (rootETag.length) {
|
||||
tags.push(rootETag)
|
||||
// thread tags
|
||||
if (rootTag) {
|
||||
tags.push(rootTag)
|
||||
}
|
||||
|
||||
if (parentETag.length) {
|
||||
tags.push(parentETag)
|
||||
if (parentTag) {
|
||||
tags.push(parentTag)
|
||||
}
|
||||
|
||||
// p tags
|
||||
@@ -640,36 +640,41 @@ function generateImetaTags(imageUrls: string[]) {
|
||||
}
|
||||
|
||||
async function extractRelatedEventIds(content: string, parentEvent?: Event) {
|
||||
let rootETag: string[] = []
|
||||
let parentETag: string[] = []
|
||||
let rootTag: string[] | null = null
|
||||
let parentTag: string[] | null = null
|
||||
|
||||
const quoteTags = extractQuoteTags(content)
|
||||
|
||||
if (parentEvent) {
|
||||
const _rootETag = getRootETag(parentEvent)
|
||||
if (_rootETag) {
|
||||
parentETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
|
||||
const _rootTag = getRootTag(parentEvent)
|
||||
if (_rootTag?.type === 'e') {
|
||||
parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
|
||||
|
||||
const [, rootEventHexId, hint, , rootEventPubkey] = _rootETag
|
||||
const [, rootEventHexId, hint, , rootEventPubkey] = _rootTag.tag
|
||||
if (rootEventPubkey) {
|
||||
rootETag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
|
||||
rootTag = buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
|
||||
} else {
|
||||
const rootEventId = generateBech32IdFromETag(_rootETag)
|
||||
const rootEventId = generateBech32IdFromETag(_rootTag.tag)
|
||||
const rootEvent = rootEventId ? await client.fetchEvent(rootEventId) : undefined
|
||||
rootETag = rootEvent
|
||||
rootTag = rootEvent
|
||||
? buildETagWithMarker(rootEvent.id, rootEvent.pubkey, hint, 'root')
|
||||
: buildETagWithMarker(rootEventHexId, rootEventPubkey, hint, 'root')
|
||||
}
|
||||
} else if (_rootTag?.type === 'a') {
|
||||
// Legacy
|
||||
parentTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'reply')
|
||||
const [, coordinate, hint] = _rootTag.tag
|
||||
rootTag = buildLegacyRootATag(coordinate, hint)
|
||||
} else {
|
||||
// reply to root event
|
||||
rootETag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
|
||||
rootTag = buildETagWithMarker(parentEvent.id, parentEvent.pubkey, '', 'root')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
quoteTags,
|
||||
rootETag,
|
||||
parentETag
|
||||
rootTag,
|
||||
parentTag
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,6 +828,16 @@ function buildETagWithMarker(
|
||||
return trimTagEnd(['e', eventHexId, hint, marker, pubkey])
|
||||
}
|
||||
|
||||
function buildLegacyRootATag(coordinate: string, hint: string = '') {
|
||||
if (!hint) {
|
||||
const evt = client.getReplaeableEventFromCache(coordinate)
|
||||
if (evt) {
|
||||
hint = client.getEventHint(evt.id)
|
||||
}
|
||||
}
|
||||
return trimTagEnd(['a', coordinate, hint, 'root'])
|
||||
}
|
||||
|
||||
function buildITag(url: string, upperCase: boolean = false) {
|
||||
return [upperCase ? 'I' : 'i', url]
|
||||
}
|
||||
|
||||
@@ -83,6 +83,14 @@ export function getParentETag(event?: Event) {
|
||||
return tag
|
||||
}
|
||||
|
||||
function getLegacyParentATag(event?: Event) {
|
||||
if (!event || event.kind !== kinds.ShortTextNote) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'reply')
|
||||
}
|
||||
|
||||
export function getParentATag(event?: Event) {
|
||||
if (
|
||||
!event ||
|
||||
@@ -114,8 +122,9 @@ export function getParentTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: strin
|
||||
if (!event) return undefined
|
||||
|
||||
if (event.kind === kinds.ShortTextNote) {
|
||||
const tag = getParentETag(event)
|
||||
return tag ? { type: 'e', tag } : undefined
|
||||
const tag = getLegacyParentATag(event) ?? getParentETag(event) ?? getLegacyRootATag(event)
|
||||
if (!tag) return undefined
|
||||
return { type: tag[0] === 'e' ? 'e' : 'a', tag }
|
||||
}
|
||||
|
||||
// NIP-22
|
||||
@@ -164,6 +173,14 @@ export function getRootETag(event?: Event) {
|
||||
return tag
|
||||
}
|
||||
|
||||
function getLegacyRootATag(event?: Event) {
|
||||
if (!event || event.kind !== kinds.ShortTextNote) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return event.tags.find(([tagName, , , marker]) => tagName === 'a' && marker === 'root')
|
||||
}
|
||||
|
||||
export function getRootATag(event?: Event) {
|
||||
if (
|
||||
!event ||
|
||||
@@ -195,8 +212,9 @@ export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[
|
||||
if (!event) return undefined
|
||||
|
||||
if (event.kind === kinds.ShortTextNote) {
|
||||
const tag = getRootETag(event)
|
||||
return tag ? { type: 'e', tag } : undefined
|
||||
const tag = getLegacyRootATag(event) ?? getRootETag(event)
|
||||
if (!tag) return undefined
|
||||
return { type: tag[0] === 'e' ? 'e' : 'a', tag }
|
||||
}
|
||||
|
||||
// NIP-22
|
||||
|
||||
@@ -16,3 +16,27 @@ export function checkNip43Support(relayInfo: TRelayInfo | undefined) {
|
||||
export function filterOutBigRelays(relayUrls: string[]) {
|
||||
return relayUrls.filter((url) => !BIG_RELAY_URLS.includes(url))
|
||||
}
|
||||
|
||||
export function recommendRelaysByLanguage(i18nLanguage: string) {
|
||||
if (i18nLanguage.startsWith('zh')) {
|
||||
return [
|
||||
'wss://relay.nostrzh.org/',
|
||||
'wss://relay.nostr.moe/',
|
||||
'wss://lang.relays.land/zh',
|
||||
'wss://relay.stream/'
|
||||
]
|
||||
}
|
||||
if (i18nLanguage.startsWith('ja')) {
|
||||
return ['wss://yabu.me/', 'wss://lang.relays.land/ja']
|
||||
}
|
||||
if (i18nLanguage.startsWith('es')) {
|
||||
return ['wss://lang.relays.land/es']
|
||||
}
|
||||
if (i18nLanguage.startsWith('it')) {
|
||||
return ['wss://lang.relays.land/it']
|
||||
}
|
||||
if (i18nLanguage.startsWith('pt')) {
|
||||
return ['wss://lang.relays.land/pt']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ const ExternalContentPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
<StuffStats className="mt-3" stuff={id} fetchIfNotExisting displayTopZapsAndLikes />
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
<ExternalContentInteractions pageIndex={index} externalContent={id} />
|
||||
<ExternalContentInteractions externalContent={id} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -105,7 +105,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
||||
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||
</div>
|
||||
<Separator className="mt-4" />
|
||||
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
|
||||
<NoteInteractions key={`note-interactions-${event.id}`} event={event} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LoginDialog from '@/components/LoginDialog'
|
||||
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, NEW_USER_RELAY_LIST } from '@/constants'
|
||||
import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import {
|
||||
createDeletionRequestDraftEvent,
|
||||
createFollowListDraftEvent,
|
||||
@@ -614,13 +614,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const setupNewUser = async (signer: ISigner) => {
|
||||
const relays = NEW_USER_RELAY_LIST.map((item) => item.url)
|
||||
await Promise.allSettled([
|
||||
client.publishEvent(relays, await signer.signEvent(createFollowListDraftEvent([]))),
|
||||
client.publishEvent(relays, await signer.signEvent(createMuteListDraftEvent([]))),
|
||||
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createFollowListDraftEvent([]))),
|
||||
client.publishEvent(BIG_RELAY_URLS, await signer.signEvent(createMuteListDraftEvent([]))),
|
||||
client.publishEvent(
|
||||
relays.concat(BIG_RELAY_URLS),
|
||||
await signer.signEvent(createRelayListDraftEvent(NEW_USER_RELAY_LIST))
|
||||
BIG_RELAY_URLS,
|
||||
await signer.signEvent(
|
||||
createRelayListDraftEvent(BIG_RELAY_URLS.map((url) => ({ url, scope: 'both' })))
|
||||
)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { getEventKey, getKeyFromTag, getParentTag, isReplyNoteEvent } from '@/lib/event'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
|
||||
type TReplyContext = {
|
||||
repliesMap: Map<string, { events: Event[]; eventKeySet: Set<string> }>
|
||||
addReplies: (replies: Event[]) => void
|
||||
}
|
||||
|
||||
const ReplyContext = createContext<TReplyContext | undefined>(undefined)
|
||||
|
||||
export const useReply = () => {
|
||||
const context = useContext(ReplyContext)
|
||||
if (!context) {
|
||||
throw new Error('useReply must be used within a ReplyProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ReplyProvider({ children }: { children: React.ReactNode }) {
|
||||
const [repliesMap, setRepliesMap] = useState<
|
||||
Map<string, { events: Event[]; eventKeySet: Set<string> }>
|
||||
>(new Map())
|
||||
|
||||
const addReplies = useCallback((replies: Event[]) => {
|
||||
const newReplyKeySet = new Set<string>()
|
||||
const newReplyEventMap = new Map<string, Event[]>()
|
||||
replies.forEach((reply) => {
|
||||
if (!isReplyNoteEvent(reply)) return
|
||||
|
||||
const key = getEventKey(reply)
|
||||
if (newReplyKeySet.has(key)) return
|
||||
newReplyKeySet.add(key)
|
||||
|
||||
const parentTag = getParentTag(reply)
|
||||
if (parentTag) {
|
||||
const parentKey = getKeyFromTag(parentTag.tag)
|
||||
if (parentKey) {
|
||||
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
|
||||
}
|
||||
}
|
||||
})
|
||||
if (newReplyEventMap.size === 0) return
|
||||
|
||||
setRepliesMap((prev) => {
|
||||
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
|
||||
const replies = prev.get(key) || { events: [], eventKeySet: new Set() }
|
||||
newReplyEvents.forEach((reply) => {
|
||||
const key = getEventKey(reply)
|
||||
if (!replies.eventKeySet.has(key)) {
|
||||
replies.events.push(reply)
|
||||
replies.eventKeySet.add(key)
|
||||
}
|
||||
})
|
||||
prev.set(key, replies)
|
||||
}
|
||||
return new Map(prev)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ReplyContext.Provider
|
||||
value={{
|
||||
repliesMap,
|
||||
addReplies
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReplyContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -842,6 +842,10 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
getReplaeableEventFromCache(coordinate: string): NEvent | undefined {
|
||||
return this.replaceableEventCacheMap.get(coordinate)
|
||||
}
|
||||
|
||||
private async fetchEventById(relayUrls: string[], id: string): Promise<NEvent | undefined> {
|
||||
const event = await this.fetchEventFromBigRelaysDataloader.load(id)
|
||||
if (event) {
|
||||
|
||||
@@ -3,28 +3,27 @@ import DataLoader from 'dataloader'
|
||||
class FayanService {
|
||||
static instance: FayanService
|
||||
|
||||
private userPercentileDataLoader = new DataLoader<string, number | null>(async (userIds) => {
|
||||
return await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
try {
|
||||
const res = await fetch(`https://fayan.jumble.social/${userId}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
return 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
const data = await res.json()
|
||||
if (typeof data.percentile === 'number') {
|
||||
return data.percentile
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
private userPercentileDataLoader = new DataLoader<string, number | null>(
|
||||
async (pubkeys) => {
|
||||
try {
|
||||
const res = await fetch(`https://fayan.jumble.social/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ pubkeys })
|
||||
})
|
||||
if (!res.ok) {
|
||||
return new Array(pubkeys.length).fill(null)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
const data = await res.json()
|
||||
return pubkeys.map((pubkey) => data[pubkey] ?? 0)
|
||||
} catch {
|
||||
return new Array(pubkeys.length).fill(null)
|
||||
}
|
||||
},
|
||||
{ maxBatchSize: 50 }
|
||||
)
|
||||
|
||||
constructor() {
|
||||
if (!FayanService.instance) {
|
||||
|
||||
378
src/services/thread.service.ts
Normal file
378
src/services/thread.service.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import {
|
||||
getEventKey,
|
||||
getKeyFromTag,
|
||||
getParentTag,
|
||||
getReplaceableCoordinateFromEvent,
|
||||
getRootTag,
|
||||
isProtectedEvent,
|
||||
isReplaceableEvent,
|
||||
isReplyNoteEvent
|
||||
} from '@/lib/event'
|
||||
import { generateBech32IdFromETag } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Filter, kinds, NostrEvent } from 'nostr-tools'
|
||||
|
||||
type TRootInfo =
|
||||
| { type: 'E'; id: string; pubkey: string }
|
||||
| { type: 'A'; id: string; pubkey: string; relay?: string }
|
||||
| { type: 'I'; id: string }
|
||||
|
||||
class ThreadService {
|
||||
static instance: ThreadService
|
||||
|
||||
private rootInfoCache = new Map<string, Promise<TRootInfo | undefined>>()
|
||||
private subscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
promise: Promise<{
|
||||
closer: () => void
|
||||
timelineKey: string
|
||||
}>
|
||||
count: number
|
||||
until?: number
|
||||
}
|
||||
>()
|
||||
private threadMap = new Map<string, NostrEvent[]>()
|
||||
private processedReplyKeys = new Set<string>()
|
||||
private parentKeyMap = new Map<string, string>()
|
||||
private descendantCache = new Map<string, Map<string, NostrEvent[]>>()
|
||||
|
||||
private threadListeners = new Map<string, Set<() => void>>()
|
||||
private allDescendantThreadsListeners = new Map<string, Set<() => void>>()
|
||||
private readonly EMPTY_ARRAY: NostrEvent[] = []
|
||||
private readonly EMPTY_MAP: Map<string, NostrEvent[]> = new Map()
|
||||
|
||||
constructor() {
|
||||
if (!ThreadService.instance) {
|
||||
ThreadService.instance = this
|
||||
}
|
||||
return ThreadService.instance
|
||||
}
|
||||
|
||||
async subscribe(stuff: NostrEvent | string, limit = 100) {
|
||||
const { event } = this.resolveStuff(stuff)
|
||||
const rootInfo = await this.parseRootInfo(stuff)
|
||||
if (!rootInfo) return
|
||||
|
||||
const subscription = this.subscriptions.get(rootInfo.id)
|
||||
if (subscription) {
|
||||
subscription.count += 1
|
||||
return
|
||||
}
|
||||
|
||||
const _subscribe = async () => {
|
||||
let relayUrls: string[] = []
|
||||
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
|
||||
if (rootPubkey) {
|
||||
const relayList = await client.fetchRelayList(rootPubkey)
|
||||
relayUrls = relayList.read
|
||||
}
|
||||
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
|
||||
|
||||
// If current event is protected, we can assume its replies are also protected and stored on the same relays
|
||||
if (event && isProtectedEvent(event)) {
|
||||
const seenOn = client.getSeenEventRelayUrls(event.id)
|
||||
relayUrls.concat(...seenOn)
|
||||
}
|
||||
|
||||
const filters: (Omit<Filter, 'since' | 'until'> & {
|
||||
limit: number
|
||||
})[] = []
|
||||
if (rootInfo.type === 'E') {
|
||||
filters.push({
|
||||
'#e': [rootInfo.id],
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit
|
||||
})
|
||||
if (event?.kind !== kinds.ShortTextNote) {
|
||||
filters.push({
|
||||
'#E': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
limit
|
||||
})
|
||||
}
|
||||
} else if (rootInfo.type === 'A') {
|
||||
filters.push(
|
||||
{
|
||||
'#a': [rootInfo.id],
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit
|
||||
},
|
||||
{
|
||||
'#A': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
limit
|
||||
}
|
||||
)
|
||||
if (rootInfo.relay) {
|
||||
relayUrls.push(rootInfo.relay)
|
||||
}
|
||||
} else {
|
||||
filters.push({
|
||||
'#I': [rootInfo.id],
|
||||
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
|
||||
limit
|
||||
})
|
||||
}
|
||||
let resolve: () => void
|
||||
const _promise = new Promise<void>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
filters.map((filter) => ({
|
||||
urls: relayUrls.slice(0, 8),
|
||||
filter
|
||||
})),
|
||||
{
|
||||
onEvents: (events, eosed) => {
|
||||
if (events.length > 0) {
|
||||
this.addRepliesToThread(events)
|
||||
}
|
||||
if (eosed) {
|
||||
const subscription = this.subscriptions.get(rootInfo.id)
|
||||
if (subscription && events.length > 0) {
|
||||
subscription.until = events[events.length - 1].created_at - 1
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
onNew: (evt) => {
|
||||
this.addRepliesToThread([evt])
|
||||
}
|
||||
}
|
||||
)
|
||||
await _promise
|
||||
return { closer, timelineKey }
|
||||
}
|
||||
|
||||
const promise = _subscribe()
|
||||
this.subscriptions.set(rootInfo.id, {
|
||||
promise,
|
||||
count: 1,
|
||||
until: dayjs().unix()
|
||||
})
|
||||
await promise
|
||||
}
|
||||
|
||||
async unsubscribe(stuff: NostrEvent | string) {
|
||||
const rootInfo = await this.parseRootInfo(stuff)
|
||||
if (!rootInfo) return
|
||||
|
||||
const subscription = this.subscriptions.get(rootInfo.id)
|
||||
if (!subscription) return
|
||||
|
||||
setTimeout(() => {
|
||||
subscription.count -= 1
|
||||
if (subscription.count <= 0) {
|
||||
this.subscriptions.delete(rootInfo.id)
|
||||
subscription.promise.then(({ closer }) => {
|
||||
closer()
|
||||
})
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
async loadMore(stuff: NostrEvent | string, limit = 100): Promise<boolean> {
|
||||
const rootInfo = await this.parseRootInfo(stuff)
|
||||
if (!rootInfo) return false
|
||||
|
||||
const subscription = this.subscriptions.get(rootInfo.id)
|
||||
if (!subscription) return false
|
||||
|
||||
const { timelineKey } = await subscription.promise
|
||||
if (!timelineKey) return false
|
||||
|
||||
if (!subscription.until) return false
|
||||
|
||||
const events = await client.loadMoreTimeline(timelineKey, subscription.until, limit)
|
||||
this.addRepliesToThread(events)
|
||||
|
||||
const { event } = this.resolveStuff(stuff)
|
||||
let newUntil = events.length ? events[events.length - 1].created_at - 1 : undefined
|
||||
if (newUntil && event && !isReplaceableEvent(event.kind) && newUntil < event.created_at) {
|
||||
newUntil = undefined
|
||||
}
|
||||
subscription.until = newUntil
|
||||
return !!newUntil
|
||||
}
|
||||
|
||||
addRepliesToThread(replies: NostrEvent[]) {
|
||||
const newReplyEventMap = new Map<string, NostrEvent[]>()
|
||||
replies.forEach((reply) => {
|
||||
const key = getEventKey(reply)
|
||||
if (this.processedReplyKeys.has(key)) return
|
||||
this.processedReplyKeys.add(key)
|
||||
|
||||
if (!isReplyNoteEvent(reply)) return
|
||||
|
||||
const parentTag = getParentTag(reply)
|
||||
if (parentTag) {
|
||||
const parentKey = getKeyFromTag(parentTag.tag)
|
||||
if (parentKey) {
|
||||
const thread = newReplyEventMap.get(parentKey) ?? []
|
||||
thread.push(reply)
|
||||
newReplyEventMap.set(parentKey, thread)
|
||||
this.parentKeyMap.set(key, parentKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (newReplyEventMap.size === 0) return
|
||||
|
||||
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
|
||||
const thread = this.threadMap.get(key) ?? []
|
||||
thread.push(...newReplyEvents)
|
||||
this.threadMap.set(key, thread)
|
||||
}
|
||||
|
||||
this.descendantCache.clear()
|
||||
for (const key of newReplyEventMap.keys()) {
|
||||
this.notifyThreadUpdate(key)
|
||||
this.notifyAllDescendantThreadsUpdate(key)
|
||||
}
|
||||
}
|
||||
|
||||
getThread(stuffKey: string): NostrEvent[] {
|
||||
return this.threadMap.get(stuffKey) ?? this.EMPTY_ARRAY
|
||||
}
|
||||
|
||||
getAllDescendantThreads(stuffKey: string): Map<string, NostrEvent[]> {
|
||||
const cached = this.descendantCache.get(stuffKey)
|
||||
if (cached) return cached
|
||||
|
||||
const build = () => {
|
||||
const thread = this.threadMap.get(stuffKey)
|
||||
if (!thread || thread.length === 0) {
|
||||
return this.EMPTY_MAP
|
||||
}
|
||||
|
||||
const result = new Map<string, NostrEvent[]>()
|
||||
const keys: string[] = [stuffKey]
|
||||
while (keys.length > 0) {
|
||||
const key = keys.pop()!
|
||||
const thread = this.threadMap.get(key) ?? []
|
||||
if (thread.length > 0) {
|
||||
result.set(key, thread)
|
||||
thread.forEach((reply) => {
|
||||
const replyKey = getEventKey(reply)
|
||||
keys.push(replyKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const allThreads = build()
|
||||
this.descendantCache.set(stuffKey, allThreads)
|
||||
return allThreads
|
||||
}
|
||||
|
||||
listenThread(key: string, callback: () => void) {
|
||||
let set = this.threadListeners.get(key)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.threadListeners.set(key, set)
|
||||
}
|
||||
set.add(callback)
|
||||
return () => {
|
||||
set?.delete(callback)
|
||||
if (set?.size === 0) this.threadListeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyThreadUpdate(key: string) {
|
||||
const set = this.threadListeners.get(key)
|
||||
if (set) {
|
||||
set.forEach((cb) => cb())
|
||||
}
|
||||
}
|
||||
|
||||
listenAllDescendantThreads(key: string, callback: () => void) {
|
||||
let set = this.allDescendantThreadsListeners.get(key)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
this.allDescendantThreadsListeners.set(key, set)
|
||||
}
|
||||
set.add(callback)
|
||||
return () => {
|
||||
set?.delete(callback)
|
||||
if (set?.size === 0) this.allDescendantThreadsListeners.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyAllDescendantThreadsUpdate(key: string) {
|
||||
const notify = (_key: string) => {
|
||||
const set = this.allDescendantThreadsListeners.get(_key)
|
||||
if (set) {
|
||||
set.forEach((cb) => cb())
|
||||
}
|
||||
}
|
||||
|
||||
notify(key)
|
||||
let parentKey = this.parentKeyMap.get(key)
|
||||
while (parentKey) {
|
||||
notify(parentKey)
|
||||
parentKey = this.parentKeyMap.get(parentKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async parseRootInfo(stuff: NostrEvent | string): Promise<TRootInfo | undefined> {
|
||||
const { event, externalContent } = this.resolveStuff(stuff)
|
||||
if (!event && !externalContent) return
|
||||
|
||||
const cacheKey = event ? getEventKey(event) : externalContent!
|
||||
const cache = this.rootInfoCache.get(cacheKey)
|
||||
if (cache) return cache
|
||||
|
||||
const _parseRootInfo = async (): Promise<TRootInfo | undefined> => {
|
||||
let root: TRootInfo = event
|
||||
? isReplaceableEvent(event.kind)
|
||||
? {
|
||||
type: 'A',
|
||||
id: getReplaceableCoordinateFromEvent(event),
|
||||
pubkey: event.pubkey,
|
||||
relay: client.getEventHint(event.id)
|
||||
}
|
||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
||||
: { type: 'I', id: externalContent! }
|
||||
|
||||
const rootTag = getRootTag(event)
|
||||
if (rootTag?.type === 'e') {
|
||||
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
|
||||
if (rootEventHexId && rootEventPubkey) {
|
||||
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
|
||||
} else {
|
||||
const rootEventId = generateBech32IdFromETag(rootTag.tag)
|
||||
if (rootEventId) {
|
||||
const rootEvent = await client.fetchEvent(rootEventId)
|
||||
if (rootEvent) {
|
||||
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (rootTag?.type === 'a') {
|
||||
const [, coordinate, relay] = rootTag.tag
|
||||
const [, pubkey] = coordinate.split(':')
|
||||
root = { type: 'A', id: coordinate, pubkey, relay }
|
||||
} else if (rootTag?.type === 'i') {
|
||||
root = { type: 'I', id: rootTag.tag[1] }
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const promise = _parseRootInfo()
|
||||
this.rootInfoCache.set(cacheKey, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
private resolveStuff(stuff: NostrEvent | string) {
|
||||
return typeof stuff === 'string'
|
||||
? { event: undefined, externalContent: stuff, stuffKey: stuff }
|
||||
: { event: stuff, externalContent: undefined, stuffKey: getEventKey(stuff) }
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new ThreadService()
|
||||
|
||||
export default instance
|
||||
11
src/types/index.d.ts
vendored
11
src/types/index.d.ts
vendored
@@ -1,5 +1,10 @@
|
||||
import { Event, Filter, VerifiedEvent } from 'nostr-tools'
|
||||
import { MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE, NSFW_DISPLAY_POLICY, POLL_TYPE } from '../constants'
|
||||
import {
|
||||
MEDIA_AUTO_LOAD_POLICY,
|
||||
NOTIFICATION_LIST_STYLE,
|
||||
NSFW_DISPLAY_POLICY,
|
||||
POLL_TYPE
|
||||
} from '../constants'
|
||||
|
||||
export type TSubRequestFilter = Omit<Filter, 'since' | 'until'> & { limit: number }
|
||||
|
||||
@@ -200,12 +205,10 @@ export type TNotificationStyle =
|
||||
export type TAwesomeRelayCollection = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
relays: string[]
|
||||
}
|
||||
|
||||
export type TMediaAutoLoadPolicy =
|
||||
(typeof MEDIA_AUTO_LOAD_POLICY)[keyof typeof MEDIA_AUTO_LOAD_POLICY]
|
||||
|
||||
export type TNsfwDisplayPolicy =
|
||||
(typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY]
|
||||
export type TNsfwDisplayPolicy = (typeof NSFW_DISPLAY_POLICY)[keyof typeof NSFW_DISPLAY_POLICY]
|
||||
|
||||
Reference in New Issue
Block a user