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 { 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 {
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 && '@'}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
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 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 }
|
||||||
|
})
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
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 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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user