feat: following list page

This commit is contained in:
codytseng
2024-11-11 17:31:14 +08:00
parent 5a091c9ec9
commit 268a160f17
15 changed files with 167 additions and 33 deletions

View File

@@ -5,6 +5,7 @@ import { Toaster } from '@renderer/components/ui/toaster'
import { ThemeProvider } from '@renderer/providers/ThemeProvider' import { ThemeProvider } from '@renderer/providers/ThemeProvider'
import { PageManager } from './PageManager' import { PageManager } from './PageManager'
import NoteListPage from './pages/primary/NoteListPage' import NoteListPage from './pages/primary/NoteListPage'
import FollowingListPage from './pages/secondary/FollowingListPage'
import HashtagPage from './pages/secondary/HashtagPage' import HashtagPage from './pages/secondary/HashtagPage'
import NotePage from './pages/secondary/NotePage' import NotePage from './pages/secondary/NotePage'
import ProfilePage from './pages/secondary/ProfilePage' import ProfilePage from './pages/secondary/ProfilePage'
@@ -15,7 +16,8 @@ import { RelaySettingsProvider } from './providers/RelaySettingsProvider'
const routes = [ const routes = [
{ pageName: 'note', element: <NotePage /> }, { pageName: 'note', element: <NotePage /> },
{ pageName: 'profile', element: <ProfilePage /> }, { pageName: 'profile', element: <ProfilePage /> },
{ pageName: 'hashtag', element: <HashtagPage /> } { pageName: 'hashtag', element: <HashtagPage /> },
{ pageName: 'followingList', element: <FollowingListPage /> }
] ]
export default function App(): JSX.Element { export default function App(): JSX.Element {

View File

@@ -1,8 +1,9 @@
import { useFetchNip05 } from '@renderer/hooks/useFetchNip05' import { useFetchNip05 } from '@renderer/hooks/useFetchNip05'
import { BadgeAlert, BadgeCheck } from 'lucide-react' 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) const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey)
return ( return (
nip05Name && nip05Name &&
nip05Domain && ( nip05Domain && (

View File

@@ -29,12 +29,16 @@ export default function Note({
<div className={className}> <div className={className}>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} /> <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 <Username
userId={event.pubkey} 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>
</div> </div>
{parentEvent && ( {parentEvent && (

View File

@@ -32,8 +32,6 @@ export default function NoteList({
}, [JSON.stringify(filter)]) }, [JSON.stringify(filter)])
useEffect(() => { useEffect(() => {
if (relayUrls.length === 0) return
setInitialized(false) setInitialized(false)
setEvents([]) setEvents([])
setNewEvents([]) setNewEvents([])
@@ -86,7 +84,7 @@ export default function NoteList({
observer.current.unobserve(bottomRef.current) observer.current.unobserve(bottomRef.current)
} }
} }
}, [until, initialized]) }, [until, initialized, hasMore])
const loadMore = async () => { const loadMore = async () => {
const events = await client.fetchEvents(relayUrls, { ...noteFilter, until }) const events = await client.fetchEvents(relayUrls, { ...noteFilter, until })

View File

@@ -2,6 +2,7 @@ import { Button } from '@renderer/components/ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger DialogTrigger
@@ -100,6 +101,7 @@ export default function PostDialog({
'New post' 'New post'
)} )}
</DialogTitle> </DialogTitle>
<DialogDescription />
</DialogHeader> </DialogHeader>
<Textarea <Textarea
onChange={handleTextareaChange} onChange={handleTextareaChange}

View File

@@ -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' : ''}`} 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" /> <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 <Username
userId={event.pubkey} userId={event.pubkey}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate" className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"

View File

@@ -20,10 +20,10 @@ export default function Username({
return ( return (
<HoverCard> <HoverCard>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<div className={className}> <div className={cn('max-w-fit', className)}>
<SecondaryPageLink <SecondaryPageLink
to={toProfile(pubkey)} to={toProfile(pubkey)}
className={cn('truncate hover:underline')} className="truncate hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{showAt && '@'} {showAt && '@'}

View File

@@ -1,2 +1,5 @@
export * from './useFetchEvent' export * from './use-toast'
export * from './useFetchEventById'
export * from './useFetchFollowings'
export * from './useFetchNip05'
export * from './useFetchProfile' export * from './useFetchProfile'

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

View File

@@ -5,3 +5,7 @@ export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/$
export const toNote = (event: Event) => ({ pageName: 'note', props: { event } }) export const toNote = (event: Event) => ({ pageName: 'note', props: { event } })
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } }) export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } })
export const toFollowingList = (pubkey: string) => ({
pageName: 'followingList',
props: { pubkey }
})

View File

@@ -4,6 +4,8 @@ import { useRelaySettings } from '@renderer/providers/RelaySettingsProvider'
export default function NoteListPage() { export default function NoteListPage() {
const { relayUrls } = useRelaySettings() const { relayUrls } = useRelaySettings()
if (!relayUrls.length) return null
return ( return (
<PrimaryPageLayout> <PrimaryPageLayout>
<NoteList relayUrls={relayUrls} /> <NoteList relayUrls={relayUrls} />

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

View File

@@ -3,10 +3,12 @@ import NoteList from '@renderer/components/NoteList'
import ProfileAbout from '@renderer/components/ProfileAbout' import ProfileAbout from '@renderer/components/ProfileAbout'
import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar'
import { Separator } from '@renderer/components/ui/separator' 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 { useFetchRelayList } from '@renderer/hooks/useFetchRelayList'
import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout'
import { toFollowingList } from '@renderer/lib/link'
import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey' import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey'
import { SecondaryPageLink } from '@renderer/PageManager'
import { Copy } from 'lucide-react' import { Copy } from 'lucide-react'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' 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 { banner, username, nip05, about, avatar } = useFetchProfile(pubkey)
const relayList = useFetchRelayList(pubkey) const relayList = useFetchRelayList(pubkey)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const followings = useFetchFollowings(pubkey)
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey]) const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : undefined), [pubkey])
const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey]) const defaultImage = useMemo(() => (pubkey ? generateImageByPubkey(pubkey) : ''), [pubkey])
@@ -42,11 +45,11 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</div> </div>
<div className="px-4 space-y-1"> <div className="px-4">
<div className="text-xl font-semibold">{username}</div> <div className="text-xl font-semibold">{username}</div>
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />} {nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
<div <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()} onClick={() => copyNpub()}
> >
{copied ? ( {copied ? (
@@ -58,9 +61,16 @@ export default function ProfilePage({ pubkey }: { pubkey?: string }) {
</> </>
)} )}
</div> </div>
<div className="text-wrap break-words whitespace-pre-wrap"> <div className="text-wrap break-words whitespace-pre-wrap mt-2">
<ProfileAbout about={about} /> <ProfileAbout about={about} />
</div> </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> </div>
<Separator className="my-4" /> <Separator className="my-4" />
<NoteList <NoteList

View File

@@ -26,11 +26,11 @@ class ClientService {
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({ private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({
max: 10000, max: 10000,
fetchMethod: async (filterStr) => { fetchMethod: async (filterStr) => {
const [event] = await this.fetchEvents( const events = await this.fetchEvents(
BIG_RELAY_URLS.concat(this.relayUrls), BIG_RELAY_URLS.concat(this.relayUrls),
JSON.parse(filterStr) JSON.parse(filterStr)
) )
return event return events.sort((a, b) => b.created_at - a.created_at)[0]
} }
}) })
private eventDataloader = new DataLoader<string, NEvent | undefined>( private eventDataloader = new DataLoader<string, NEvent | undefined>(
@@ -91,7 +91,10 @@ class ClientService {
) { ) {
const events: NEvent[] = [] const events: NEvent[] = []
let eose = false let eose = false
return this.pool.subscribeMany(urls, [filter], { return this.pool.subscribeMany(
urls.length > 0 ? urls : this.relayUrls.concat(BIG_RELAY_URLS),
[filter],
{
onevent: (evt) => { onevent: (evt) => {
if (eose) { if (eose) {
opts.onNew(evt) opts.onNew(evt)
@@ -108,7 +111,8 @@ class ClientService {
opts.onEose(events.sort((a, b) => b.created_at - a.created_at)) opts.onEose(events.sort((a, b) => b.created_at - a.created_at))
} }
} }
}) }
)
} }
async fetchEvents(relayUrls: string[], filter: Filter) { async fetchEvents(relayUrls: string[], filter: Filter) {