19 Commits

Author SHA1 Message Date
codytseng
e60a460480 fix: adjust button layout for download and copy actions in Signup component 2025-12-26 09:29:50 +08:00
bitcoinuser
81667112d1 feat: update Portuguese translations for backup messages (#705) 2025-12-25 23:18:26 +08:00
codytseng
c60d7ab401 feat: adjust default relay configuration 2025-12-25 23:14:52 +08:00
codytseng
e25902b8b4 refactor: 🏗️ 2025-12-25 23:03:44 +08:00
codytseng
d964c7b7b3 fix: return 0 instead of null for missing user percentile data 2025-12-25 09:21:29 +08:00
codytseng
25b2831fcc feat: 💨 2025-12-24 23:31:18 +08:00
bitcoinuser
1553227e13 feat: improve signup copy in Portuguese translations (#703) 2025-12-24 22:58:26 +08:00
codytseng
f04981f5b9 fix: improve description display in RelaySimpleInfo component 2025-12-24 22:54:58 +08:00
codytseng
2662373704 fix: adjust layout for Signup component 2025-12-24 22:51:59 +08:00
codytseng
526b64aec0 feat: add border to image hash placeholder 2025-12-24 22:48:38 +08:00
codytseng
41a65338b5 fix: 🐛 2025-12-24 22:30:00 +08:00
codytseng
56f0aa9fd5 fix: 🐛 2025-12-24 13:22:38 +08:00
codytseng
89f79b999c refactor: reverse top-level replies order 2025-12-24 13:01:03 +08:00
bitcoinuser
7459a3d33a feat: update Portuguese translations for clarity and accuracy (#702) 2025-12-24 10:58:24 +08:00
codytseng
49eca495f5 refactor: 🎨 2025-12-24 10:55:05 +08:00
codytseng
96abe5f24f feat: add compatibility for legacy comments 2025-12-23 23:30:57 +08:00
codytseng
0ee93718da feat: add relay recommendations based on user language 2025-12-23 22:28:07 +08:00
codytseng
a880a92748 feat: simplify account creation flow 2025-12-23 21:52:32 +08:00
codytseng
cd7c52eda0 feat: batch fetch user percentiles 2025-12-22 22:34:29 +08:00
57 changed files with 904 additions and 560 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
) : (

View File

@@ -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" />}
/>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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)
)
}

View File

@@ -633,6 +633,7 @@ export default {
'أضف كلمة مرور لتشفير مفتاحك الخاص في هذا المتصفح. هذا اختياري لكنه موصى به بشدة لأمان أفضل.',
'Create a password (or skip)': 'أنشئ كلمة مرور (أو تخطى)',
'Enter your password again': 'أدخل كلمة المرور مرة أخرى',
'Complete Signup': 'إكمال التسجيل'
'Complete Signup': 'إكمال التسجيل',
Recommended: 'موصى به'
}
}

View File

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

View File

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

View File

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

View File

@@ -643,6 +643,7 @@ export default {
'یک رمز عبور برای رمزگذاری کلید خصوصی خود در این مرورگر اضافه کنید. این اختیاری است اما برای امنیت بهتر به شدت توصیه می‌شود.',
'Create a password (or skip)': 'یک رمز عبور ایجاد کنید (یا رد کنید)',
'Enter your password again': 'رمز عبور خود را دوباره وارد کنید',
'Complete Signup': 'تکمیل ثبت‌نام'
'Complete Signup': 'تکمیل ثبت‌نام',
Recommended: 'توصیه شده'
}
}

View File

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

View File

@@ -644,6 +644,7 @@ export default {
'इस ब्राउज़र में अपनी निजी कुंजी को एन्क्रिप्ट करने के लिए पासवर्ड जोड़ें। यह वैकल्पिक है लेकिन बेहतर सुरक्षा के लिए दृढ़ता से अनुशंसित है।',
'Create a password (or skip)': 'एक पासवर्ड बनाएं (या छोड़ें)',
'Enter your password again': 'अपना पासवर्ड फिर से दर्ज करें',
'Complete Signup': 'साइनअप पूर्ण करें'
'Complete Signup': 'साइनअप पूर्ण करें',
Recommended: 'अनुशंसित'
}
}

View File

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

View File

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

View File

@@ -642,6 +642,7 @@ export default {
'このブラウザで秘密鍵を暗号化するパスワードを追加します。オプションですが、より良いセキュリティのために強くお勧めします。',
'Create a password (or skip)': 'パスワードを作成(またはスキップ)',
'Enter your password again': 'パスワードをもう一度入力',
'Complete Signup': '登録を完了'
'Complete Signup': '登録を完了',
Recommended: 'おすすめ'
}
}

View File

@@ -639,6 +639,7 @@ export default {
'이 브라우저에서 개인 키를 암호화할 비밀번호를 추가합니다. 선택사항이지만 더 나은 보안을 위해 강력히 권장합니다.',
'Create a password (or skip)': '비밀번호 생성(또는 건너뛰기)',
'Enter your password again': '비밀번호를 다시 입력하세요',
'Complete Signup': '가입 완료'
'Complete Signup': '가입 완료',
Recommended: '추천'
}
}

View File

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

View File

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

View File

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

View File

@@ -648,6 +648,7 @@ export default {
'Добавьте пароль для шифрования вашего приватного ключа в этом браузере. Это необязательно, но настоятельно рекомендуется для лучшей безопасности.',
'Create a password (or skip)': 'Создайте пароль (или пропустите)',
'Enter your password again': 'Введите пароль еще раз',
'Complete Signup': 'Завершить регистрацию'
'Complete Signup': 'Завершить регистрацию',
Recommended: 'Рекомендуемые'
}
}

View File

@@ -633,6 +633,7 @@ export default {
'เพิ่มรหัสผ่านเพื่อเข้ารหัสคีย์ส่วนตัวของคุณในเบราว์เซอร์นี้ เป็นตัวเลือก แต่แนะนำอย่างยิ่งเพื่อความปลอดภัยที่ดีขึ้น',
'Create a password (or skip)': 'สร้างรหัสผ่าน (หรือข้าม)',
'Enter your password again': 'ป้อนรหัสผ่านของคุณอีกครั้ง',
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน'
'Complete Signup': 'เสร็จสิ้นการลงทะเบียน',
Recommended: 'แนะนำ'
}
}

View File

@@ -619,6 +619,7 @@ export default {
'新增密碼以在此瀏覽器中加密你的私鑰。這是可選的,但強烈建議設定以獲得更好的安全性。',
'Create a password (or skip)': '建立密碼(或跳過)',
'Enter your password again': '再次輸入你的密碼',
'Complete Signup': '完成註冊'
'Complete Signup': '完成註冊',
Recommended: '推薦'
}
}

View File

@@ -624,6 +624,7 @@ export default {
'添加密码以在此浏览器中加密你的私钥。这是可选的,但强烈建议设置以获得更好的安全性。',
'Create a password (or skip)': '创建密码(或跳过)',
'Enter your password again': '再次输入你的密码',
'Complete Signup': '完成注册'
'Complete Signup': '完成注册',
Recommended: '推荐'
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View File

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