feat: following list page
This commit is contained in:
@@ -5,6 +5,7 @@ import { Toaster } from '@renderer/components/ui/toaster'
|
||||
import { ThemeProvider } from '@renderer/providers/ThemeProvider'
|
||||
import { PageManager } from './PageManager'
|
||||
import NoteListPage from './pages/primary/NoteListPage'
|
||||
import FollowingListPage from './pages/secondary/FollowingListPage'
|
||||
import HashtagPage from './pages/secondary/HashtagPage'
|
||||
import NotePage from './pages/secondary/NotePage'
|
||||
import ProfilePage from './pages/secondary/ProfilePage'
|
||||
@@ -15,7 +16,8 @@ import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
|
||||
const routes = [
|
||||
{ pageName: 'note', element: <NotePage /> },
|
||||
{ pageName: 'profile', element: <ProfilePage /> },
|
||||
{ pageName: 'hashtag', element: <HashtagPage /> }
|
||||
{ pageName: 'hashtag', element: <HashtagPage /> },
|
||||
{ pageName: 'followingList', element: <FollowingListPage /> }
|
||||
]
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useFetchNip05 } from '@renderer/hooks/useFetchNip05'
|
||||
import { BadgeAlert, BadgeCheck } from 'lucide-react'
|
||||
|
||||
export default function Nip05({ nip05, pubkey }: { nip05: string; pubkey: string }) {
|
||||
export default function Nip05({ nip05, pubkey }: { nip05?: string; pubkey: string }) {
|
||||
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey)
|
||||
|
||||
return (
|
||||
nip05Name &&
|
||||
nip05Domain && (
|
||||
|
||||
@@ -29,12 +29,16 @@ export default function Note({
|
||||
<div className={className}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
|
||||
<div className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-end' : ''}`}>
|
||||
<div
|
||||
className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-end overflow-hidden' : ''}`}
|
||||
>
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className={`font-semibold max-w-fit flex ${size === 'small' ? 'text-sm' : ''}`}
|
||||
className={`font-semibold flex ${size === 'small' ? 'text-sm' : ''}`}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{formatTimestamp(event.created_at)}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{formatTimestamp(event.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{parentEvent && (
|
||||
|
||||
@@ -32,8 +32,6 @@ export default function NoteList({
|
||||
}, [JSON.stringify(filter)])
|
||||
|
||||
useEffect(() => {
|
||||
if (relayUrls.length === 0) return
|
||||
|
||||
setInitialized(false)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
@@ -86,7 +84,7 @@ export default function NoteList({
|
||||
observer.current.unobserve(bottomRef.current)
|
||||
}
|
||||
}
|
||||
}, [until, initialized])
|
||||
}, [until, initialized, hasMore])
|
||||
|
||||
const loadMore = async () => {
|
||||
const events = await client.fetchEvents(relayUrls, { ...noteFilter, until })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
@@ -100,6 +101,7 @@ export default function PostDialog({
|
||||
'New post'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
onChange={handleTextareaChange}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ReplyNote({
|
||||
className={`flex space-x-2 items-start rounded-lg p-2 transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`}
|
||||
>
|
||||
<UserAvatar userId={event.pubkey} size="small" className="shrink-0" />
|
||||
<div className="w-full overflow-hidden space-y-1">
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
|
||||
@@ -20,10 +20,10 @@ export default function Username({
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className={className}>
|
||||
<div className={cn('max-w-fit', className)}>
|
||||
<SecondaryPageLink
|
||||
to={toProfile(pubkey)}
|
||||
className={cn('truncate hover:underline')}
|
||||
className="truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showAt && '@'}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from './useFetchEvent'
|
||||
export * from './use-toast'
|
||||
export * from './useFetchEventById'
|
||||
export * from './useFetchFollowings'
|
||||
export * from './useFetchNip05'
|
||||
export * from './useFetchProfile'
|
||||
|
||||
33
src/renderer/src/hooks/useFetchFollowings.tsx
Normal file
33
src/renderer/src/hooks/useFetchFollowings.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { tagNameEquals } from '@renderer/lib/tag'
|
||||
import client from '@renderer/services/client.service'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useFetchFollowings(pubkey?: string) {
|
||||
const [followings, setFollowings] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (!pubkey) return
|
||||
|
||||
const followListEvent = await client.fetchEventByFilter({
|
||||
authors: [pubkey],
|
||||
kinds: [kinds.Contacts],
|
||||
limit: 1
|
||||
})
|
||||
if (!followListEvent) return
|
||||
|
||||
setFollowings(
|
||||
followListEvent.tags
|
||||
.filter(tagNameEquals('p'))
|
||||
.map(([, pubkey]) => pubkey)
|
||||
.filter(Boolean)
|
||||
.reverse()
|
||||
)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [pubkey])
|
||||
|
||||
return followings
|
||||
}
|
||||
@@ -5,3 +5,7 @@ export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/$
|
||||
export const toNote = (event: Event) => ({ pageName: 'note', props: { event } })
|
||||
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||
export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } })
|
||||
export const toFollowingList = (pubkey: string) => ({
|
||||
pageName: 'followingList',
|
||||
props: { pubkey }
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
|
||||
|
||||
export default function NoteListPage() {
|
||||
const { relayUrls } = useRelaySettings()
|
||||
if (!relayUrls.length) return null
|
||||
|
||||
return (
|
||||
<PrimaryPageLayout>
|
||||
<NoteList relayUrls={relayUrls} />
|
||||
|
||||
71
src/renderer/src/pages/secondary/FollowingListPage/index.tsx
Normal file
71
src/renderer/src/pages/secondary/FollowingListPage/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import Nip05 from '@renderer/components/Nip05'
|
||||
import UserAvatar from '@renderer/components/UserAvatar'
|
||||
import Username from '@renderer/components/Username'
|
||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
const { username } = useFetchProfile(pubkey)
|
||||
const followings = useFetchFollowings(pubkey)
|
||||
const [visibleFollowings, setVisibleFollowings] = useState<string[]>([])
|
||||
const observer = useRef<IntersectionObserver | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleFollowings(followings.slice(0, 10))
|
||||
}, [followings])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
observer.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && followings.length > visibleFollowings.length) {
|
||||
setVisibleFollowings((prev) => [
|
||||
...prev,
|
||||
...followings.slice(prev.length, prev.length + 10)
|
||||
])
|
||||
}
|
||||
}, options)
|
||||
|
||||
if (bottomRef.current) {
|
||||
observer.current.observe(bottomRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observer.current && bottomRef.current) {
|
||||
observer.current.unobserve(bottomRef.current)
|
||||
}
|
||||
}
|
||||
}, [visibleFollowings])
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout titlebarContent={username ? `${username}'s following` : 'following'}>
|
||||
<div className="space-y-2">
|
||||
{visibleFollowings.map((pubkey, index) => (
|
||||
<FollowingItem key={index} pubkey={pubkey} />
|
||||
))}
|
||||
{followings.length > visibleFollowings.length && <div ref={bottomRef} />}
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function FollowingItem({ pubkey }: { pubkey: string }) {
|
||||
const { about, nip05 } = useFetchProfile(pubkey)
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<UserAvatar userId={pubkey} />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username userId={pubkey} className="font-semibold truncate" />
|
||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground">{about}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,12 @@ import NoteList from '@renderer/components/NoteList'
|
||||
import ProfileAbout from '@renderer/components/ProfileAbout'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { useFetchProfile } from '@renderer/hooks'
|
||||
import { useFetchFollowings, useFetchProfile } from '@renderer/hooks'
|
||||
import { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
|
||||
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
|
||||
import { toFollowingList } from '@renderer/lib/link'
|
||||
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
|
||||
import { SecondaryPageLink } from '@renderer/PageManager'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -15,6 +17,7 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
const { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
|
||||
const relayList = useFetchRelayList(pubkey)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const followings = useFetchFollowings(pubkey)
|
||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
|
||||
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
|
||||
|
||||
@@ -42,11 +45,11 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="px-4 space-y-1">
|
||||
<div className="px-4">
|
||||
<div className="text-xl font-semibold">{username}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
||||
<div
|
||||
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full hover:text-foreground cursor-pointer"
|
||||
className="mt-1 flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full hover:text-foreground cursor-pointer"
|
||||
onClick={() => copyNpub()}
|
||||
>
|
||||
{copied ? (
|
||||
@@ -58,9 +61,16 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-wrap break-words whitespace-pre-wrap">
|
||||
<div className="text-wrap break-words whitespace-pre-wrap mt-2">
|
||||
<ProfileAbout about={about} />
|
||||
</div>
|
||||
<SecondaryPageLink
|
||||
to={toFollowingList(pubkey)}
|
||||
className="mt-2 flex gap-1 hover:underline text-sm"
|
||||
>
|
||||
{followings.length}
|
||||
<div className="text-muted-foreground">Following</div>
|
||||
</SecondaryPageLink>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<NoteList
|
||||
|
||||
@@ -26,11 +26,11 @@ class ClientService {
|
||||
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
max: 10000,
|
||||
fetchMethod: async (filterStr) => {
|
||||
const [event] = await this.fetchEvents(
|
||||
const events = await this.fetchEvents(
|
||||
BIG_RELAY_URLS.concat(this.relayUrls),
|
||||
JSON.parse(filterStr)
|
||||
)
|
||||
return event
|
||||
return events.sort((a, b) => b.created_at - a.created_at)[0]
|
||||
}
|
||||
})
|
||||
private eventDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
@@ -91,24 +91,28 @@ class ClientService {
|
||||
) {
|
||||
const events: NEvent[] = []
|
||||
let eose = false
|
||||
return this.pool.subscribeMany(urls, [filter], {
|
||||
onevent: (evt) => {
|
||||
if (eose) {
|
||||
opts.onNew(evt)
|
||||
} else {
|
||||
events.push(evt)
|
||||
}
|
||||
},
|
||||
oneose: () => {
|
||||
eose = true
|
||||
opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
|
||||
},
|
||||
onclose: () => {
|
||||
if (!eose) {
|
||||
return this.pool.subscribeMany(
|
||||
urls.length > 0 ? urls : this.relayUrls.concat(BIG_RELAY_URLS),
|
||||
[filter],
|
||||
{
|
||||
onevent: (evt) => {
|
||||
if (eose) {
|
||||
opts.onNew(evt)
|
||||
} else {
|
||||
events.push(evt)
|
||||
}
|
||||
},
|
||||
oneose: () => {
|
||||
eose = true
|
||||
opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
|
||||
},
|
||||
onclose: () => {
|
||||
if (!eose) {
|
||||
opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async fetchEvents(relayUrls: string[], filter: Filter) {
|
||||
|
||||
Reference in New Issue
Block a user