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

View File

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

View File

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

View File

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

View File

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

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' : ''}`}
>
<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"

View File

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

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'

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

View File

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

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

View File

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