Browse Source

Added "You" tab and follow pack support

imwald
Silberengel 4 months ago
parent
commit
5aa78956fd
  1. 283
      src/components/Profile/ProfileInteractions.tsx
  2. 103
      src/components/Profile/index.tsx
  3. 1
      src/lib/link.ts
  4. 244
      src/pages/secondary/FollowPacksPage/index.tsx
  5. 4
      src/routes.tsx

283
src/components/Profile/ProfileInteractions.tsx

@ -0,0 +1,283 @@ @@ -0,0 +1,283 @@
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { Event, kinds } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState, useRef, useCallback } from 'react'
import client from '@/services/client.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { useZap } from '@/providers/ZapProvider'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
interface ProfileInteractionsProps {
accountPubkey: string
profilePubkey: string
topSpace?: number
searchQuery?: string
onEventsChange?: (events: Event[]) => void
}
const ProfileInteractions = forwardRef<
{ refresh: () => void; getEvents?: () => Event[] },
ProfileInteractionsProps
>(
(
{
accountPubkey,
profilePubkey,
topSpace,
searchQuery = '',
onEventsChange
},
ref
) => {
const { zapReplyThreshold } = useZap()
const [isRefreshing, setIsRefreshing] = useState(false)
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const [events, setEvents] = useState<Event[]>([])
const [isLoading, setIsLoading] = useState(true)
const [refreshToken, setRefreshToken] = useState(0)
const bottomRef = useRef<HTMLDivElement>(null)
const fetchInteractions = useCallback(async () => {
setIsLoading(true)
try {
const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url)
// Fetch events where accountPubkey interacted with profilePubkey
// 1. Replies: accountPubkey replied to profilePubkey's notes
// 2. Zaps: accountPubkey zapped profilePubkey
// 3. Mentions: accountPubkey mentioned profilePubkey
// 4. Replies to accountPubkey: profilePubkey replied to accountPubkey's notes
const filters: any[] = []
// Get profilePubkey's notes to find replies to them
const profileNotes = await client.fetchEvents(relayUrls, [{
authors: [profilePubkey],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION],
limit: 100
}])
const profileNoteIds = profileNotes.map(e => e.id)
// Replies from accountPubkey to profilePubkey's notes
if (profileNoteIds.length > 0) {
filters.push({
authors: [accountPubkey],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
'#e': profileNoteIds,
limit: 100
})
}
// Zaps from accountPubkey to profilePubkey
filters.push({
authors: [accountPubkey],
kinds: [kinds.Zap],
'#p': [profilePubkey],
limit: 100
})
// Mentions: accountPubkey mentioned profilePubkey
filters.push({
authors: [accountPubkey],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE],
'#p': [profilePubkey],
limit: 100
})
// Get accountPubkey's notes to find replies from profilePubkey
const accountNotes = await client.fetchEvents(relayUrls, [{
authors: [accountPubkey],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.DISCUSSION],
limit: 100
}])
const accountNoteIds = accountNotes.map(e => e.id)
// Replies from profilePubkey to accountPubkey's notes
if (accountNoteIds.length > 0) {
filters.push({
authors: [profilePubkey],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
'#e': accountNoteIds,
limit: 100
})
}
// Zaps from profilePubkey to accountPubkey
filters.push({
authors: [profilePubkey],
kinds: [kinds.Zap],
'#p': [accountPubkey],
limit: 100
})
// Mentions: profilePubkey mentioned accountPubkey
filters.push({
authors: [profilePubkey],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.POLL, ExtendedKind.PUBLIC_MESSAGE],
'#p': [accountPubkey],
limit: 100
})
const allEvents = await client.fetchEvents(relayUrls, filters)
// Deduplicate and filter
const seenIds = new Set<string>()
const uniqueEvents = allEvents.filter(event => {
if (seenIds.has(event.id)) return false
seenIds.add(event.id)
// Filter zap receipts below threshold
if (event.kind === ExtendedKind.ZAP_RECEIPT) {
const zapInfo = getZapInfoFromEvent(event)
if (!zapInfo?.amount || zapInfo.amount < zapReplyThreshold) {
return false
}
}
return true
})
// Sort by created_at descending
uniqueEvents.sort((a, b) => b.created_at - a.created_at)
setEvents(uniqueEvents)
} catch (error) {
console.error('Failed to fetch interactions', error)
setEvents([])
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}, [accountPubkey, profilePubkey, zapReplyThreshold])
useEffect(() => {
if (!accountPubkey || !profilePubkey) return
fetchInteractions()
}, [accountPubkey, profilePubkey, refreshToken, fetchInteractions])
useEffect(() => {
onEventsChange?.(events)
}, [events, onEventsChange])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
setRefreshToken((prev) => prev + 1)
},
getEvents: () => events
}),
[events]
)
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) {
return events
}
const query = searchQuery.toLowerCase().trim()
return events.filter((event) => {
const contentLower = event.content.toLowerCase()
if (contentLower.includes(query)) return true
return event.tags.some((tag) => {
if (tag.length <= 1) return false
const tagValue = tag[1]
return tagValue && tagValue.toLowerCase().includes(query)
})
})
}, [events, searchQuery])
// Reset showCount when filters change
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
}, [searchQuery])
// Pagination: slice to showCount for display
const displayedEvents = useMemo(() => {
return filteredEvents.slice(0, showCount)
}, [filteredEvents, showCount])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length))
}
},
{ threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => {
observer.disconnect()
}
}, [displayedEvents.length, filteredEvents.length])
if (!accountPubkey || !profilePubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No interactions to show</div>
</div>
)
}
if (isLoading && events.length === 0) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (!filteredEvents.length && !isLoading) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim() ? 'No interactions match your search' : 'No interactions found'}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing interactions...</div>
)}
{searchQuery.trim() && (
<div className="px-4 py-2 text-sm text-muted-foreground">
Showing {displayedEvents.length} of {filteredEvents.length} interactions
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
{displayedEvents.length < filteredEvents.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">Loading more...</div>
</div>
)}
</div>
)
}
)
ProfileInteractions.displayName = 'ProfileInteractions'
export default ProfileInteractions

103
src/components/Profile/index.tsx

@ -43,8 +43,10 @@ import SmartFollowings from './SmartFollowings' @@ -43,8 +43,10 @@ import SmartFollowings from './SmartFollowings'
import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
import ProfileMedia from './ProfileMedia'
import ProfileInteractions from './ProfileInteractions'
import { toFollowPacks } from '@/lib/link'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you'
export default function Profile({ id }: { id?: string }) {
const { t } = useTranslation()
@ -134,9 +136,11 @@ export default function Profile({ id }: { id?: string }) { @@ -134,9 +136,11 @@ export default function Profile({ id }: { id?: string }) {
const profileBookmarksRef = useRef<{ refresh: () => void }>(null)
const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null)
const profileMediaRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null)
const profileInteractionsRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null)
const [articleEvents, setArticleEvents] = useState<Event[]>([])
const [postEvents, setPostEvents] = useState<Event[]>([])
const [mediaEvents, setMediaEvents] = useState<Event[]>([])
const [interactionEvents, setInteractionEvents] = useState<Event[]>([])
const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component
@ -156,38 +160,52 @@ export default function Profile({ id }: { id?: string }) { @@ -156,38 +160,52 @@ export default function Profile({ id }: { id?: string }) {
profileArticlesRef.current?.refresh()
} else if (activeTab === 'media') {
profileMediaRef.current?.refresh()
} else if (activeTab === 'you') {
profileInteractionsRef.current?.refresh()
} else {
profileBookmarksRef.current?.refresh()
}
}
// Define tabs with refresh buttons
const tabs = useMemo(() => [
{
value: 'posts',
label: 'Posts'
},
{
value: 'articles',
label: 'Articles'
},
{
value: 'media',
label: 'Media'
},
{
value: 'pins',
label: 'Pins'
},
{
value: 'bookmarks',
label: 'Bookmarks'
},
{
value: 'interests',
label: 'Interests'
const tabs = useMemo(() => {
const baseTabs = [
{
value: 'posts',
label: 'Posts'
},
{
value: 'articles',
label: 'Articles'
},
{
value: 'media',
label: 'Media'
},
{
value: 'pins',
label: 'Pins'
},
{
value: 'bookmarks',
label: 'Bookmarks'
},
{
value: 'interests',
label: 'Interests'
}
]
// Add "You" tab if viewing another user's profile and logged in
if (!isSelf && accountPubkey) {
baseTabs.push({
value: 'you',
label: 'You'
})
}
], [])
return baseTabs
}, [isSelf, accountPubkey])
useEffect(() => {
if (!profile?.pubkey) return
@ -245,13 +263,22 @@ export default function Profile({ id }: { id?: string }) { @@ -245,13 +263,22 @@ export default function Profile({ id }: { id?: string }) {
<div className="flex justify-end h-8 gap-2 items-center">
<ProfileOptions pubkey={pubkey} />
{isSelf ? (
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={() => push(toProfileEditor())}
>
{t('Edit')}
</Button>
<div className="flex gap-2">
<Button
className="rounded-full whitespace-nowrap"
variant="secondary"
onClick={() => push(toFollowPacks())}
>
{t('Browse follow packs')}
</Button>
<Button
className="w-20 min-w-20 rounded-full"
variant="secondary"
onClick={() => push(toProfileEditor())}
>
{t('Edit')}
</Button>
</div>
) : (
<>
{!!lightningAddress && <ProfileZapButton pubkey={pubkey} />}
@ -442,6 +469,16 @@ export default function Profile({ id }: { id?: string }) { @@ -442,6 +469,16 @@ export default function Profile({ id }: { id?: string }) {
searchQuery={searchQuery}
/>
)}
{activeTab === 'you' && accountPubkey && (
<ProfileInteractions
ref={profileInteractionsRef}
accountPubkey={accountPubkey}
profilePubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
onEventsChange={setInteractionEvents}
/>
)}
</div>
</>
)

1
src/lib/link.ts

@ -75,6 +75,7 @@ export const toProfileEditor = () => '/profile-editor' @@ -75,6 +75,7 @@ export const toProfileEditor = () => '/profile-editor'
export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}`
export const toRelayReviews = (url: string) => `/relays/${encodeURIComponent(url)}/reviews`
export const toMuteList = () => '/mutes'
export const toFollowPacks = () => '/follow-packs'
export const toChachiChat = (relay: string, d: string) => {
return `https://chachi.chat/${relay.replace(/^wss?:\/\//, '').replace(/\/$/, '')}/${d}`

244
src/pages/secondary/FollowPacksPage/index.tsx

@ -0,0 +1,244 @@ @@ -0,0 +1,244 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useFollowList } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { getPubkeysFromPTags } from '@/lib/tag'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import client from '@/services/client.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { Users, Loader2 } from 'lucide-react'
import logger from '@/lib/logger'
import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
const FollowPacksPage = forwardRef<HTMLDivElement, { index?: number; hideTitlebar?: boolean }>(
({ index, hideTitlebar = false }, ref) => {
const { t } = useTranslation()
const { pubkey } = useNostr()
const { followings, follow } = useFollowList()
const [packs, setPacks] = useState<Event[]>([])
const [isLoading, setIsLoading] = useState(true)
const [followingPacks, setFollowingPacks] = useState<Set<string>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const fetchPacks = async () => {
if (!pubkey) return
setIsLoading(true)
try {
const relayUrls = FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url)
// Fetch kind 39089 events (starter packs)
const events = await client.fetchEvents(relayUrls, [{
kinds: [39089],
limit: 100
}])
// Sort by created_at descending
events.sort((a, b) => b.created_at - a.created_at)
setPacks(events)
// Check which packs the user is already following all members of
const followingSet = new Set(followings)
const packsFollowingAll = new Set<string>()
events.forEach(pack => {
const packPubkeys = getPubkeysFromPTags(pack.tags)
if (packPubkeys.length > 0 && packPubkeys.every(p => followingSet.has(p))) {
packsFollowingAll.add(pack.id)
}
})
setFollowingPacks(packsFollowingAll)
} catch (error) {
logger.error('Failed to fetch follow packs', { error })
toast.error(t('Failed to load follow packs'))
} finally {
setIsLoading(false)
}
}
fetchPacks()
}, [pubkey, followings])
const handleFollowPack = async (pack: Event) => {
if (!pubkey) {
toast.error(t('Please log in to follow'))
return
}
const packPubkeys = getPubkeysFromPTags(pack.tags)
const followingSet = new Set(followings)
const toFollow = packPubkeys.filter(p => !followingSet.has(p))
if (toFollow.length === 0) {
toast.info(t('You are already following all members of this pack'))
return
}
try {
// Follow all pubkeys in the pack
for (const pubkeyToFollow of toFollow) {
await follow(pubkeyToFollow)
}
toast.success(t('Followed {{count}} users', { count: toFollow.length }))
// Update followingPacks if all members are now followed
if (packPubkeys.every(p => followingSet.has(p) || toFollow.includes(p))) {
setFollowingPacks(prev => new Set([...prev, pack.id]))
}
} catch (error) {
logger.error('Failed to follow pack', { error })
toast.error(t('Failed to follow pack') + ': ' + (error as Error).message)
}
}
const getPackTitle = (pack: Event): string => {
const titleTag = pack.tags.find(tag => tag[0] === 'title' || tag[0] === 'name')
return titleTag?.[1] || t('Follow Pack')
}
const getPackDescription = (pack: Event): string => {
const descTag = pack.tags.find(tag => tag[0] === 'description' || tag[0] === 'd')
return descTag?.[1] || ''
}
const filteredPacks = useMemo(() => {
if (!searchQuery.trim()) {
return packs
}
const query = searchQuery.toLowerCase().trim()
return packs.filter(pack => {
const titleTag = pack.tags.find(tag => tag[0] === 'title' || tag[0] === 'name')
const title = (titleTag?.[1] || t('Follow Pack')).toLowerCase()
const descTag = pack.tags.find(tag => tag[0] === 'description' || tag[0] === 'd')
const description = (descTag?.[1] || '').toLowerCase()
return title.includes(query) || description.includes(query)
})
}, [packs, searchQuery, t])
if (!pubkey) {
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Browse Follow Packs')} hideBackButton={hideTitlebar}>
<div className="flex flex-col items-center justify-center py-16">
<div className="text-lg font-semibold mb-2">{t('Please log in')}</div>
<div className="text-sm text-muted-foreground">{t('You need to be logged in to browse follow packs')}</div>
</div>
</SecondaryPageLayout>
)
}
return (
<SecondaryPageLayout ref={ref} index={index} title={t('Browse Follow Packs')} hideBackButton={hideTitlebar} displayScrollToTopButton>
<div className="space-y-4 p-4">
{!isLoading && packs.length > 0 && (
<div className="flex items-center gap-2">
<ProfileSearchBar
onSearch={setSearchQuery}
placeholder={t('Search follow packs by name...')}
className="w-full max-w-md"
/>
</div>
)}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-full mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
) : packs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="text-lg font-semibold mb-2">{t('No follow packs found')}</div>
<div className="text-sm text-muted-foreground">{t('There are no follow packs available at the moment')}</div>
</div>
) : filteredPacks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="text-lg font-semibold mb-2">{t('No packs match your search')}</div>
<div className="text-sm text-muted-foreground">{t('Try a different search term')}</div>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPacks.map((pack) => {
const packPubkeys = getPubkeysFromPTags(pack.tags)
const followingSet = new Set(followings)
const alreadyFollowingAll = packPubkeys.length > 0 && packPubkeys.every(p => followingSet.has(p))
const toFollowCount = packPubkeys.filter(p => !followingSet.has(p)).length
return (
<Card key={pack.id}>
<CardHeader>
<CardTitle className="text-lg">{getPackTitle(pack)}</CardTitle>
{getPackDescription(pack) && (
<CardDescription className="line-clamp-2">{getPackDescription(pack)}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="size-4" />
<span>{t('{{count}} profiles', { count: packPubkeys.length })}</span>
</div>
{packPubkeys.length > 0 && (
<div className="flex -space-x-2">
{packPubkeys.slice(0, 5).map((pubkey) => (
<SimpleUserAvatar
key={pubkey}
userId={pubkey}
size="small"
className="border-2 border-background"
/>
))}
{packPubkeys.length > 5 && (
<div className="size-8 rounded-full border-2 border-background bg-muted flex items-center justify-center text-xs">
+{packPubkeys.length - 5}
</div>
)}
</div>
)}
<Button
className="w-full"
onClick={() => handleFollowPack(pack)}
disabled={alreadyFollowingAll}
variant={alreadyFollowingAll ? 'secondary' : 'default'}
>
{alreadyFollowingAll ? (
t('Following All')
) : (
<>
{t('Follow')} {toFollowCount > 0 && `(${toFollowCount})`}
</>
)}
</Button>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</SecondaryPageLayout>
)
})
FollowPacksPage.displayName = 'FollowPacksPage'
export default FollowPacksPage

4
src/routes.tsx

@ -18,6 +18,7 @@ import SearchPage from './pages/secondary/SearchPage' @@ -18,6 +18,7 @@ import SearchPage from './pages/secondary/SearchPage'
import SettingsPage from './pages/secondary/SettingsPage'
import TranslationPage from './pages/secondary/TranslationPage'
import WalletPage from './pages/secondary/WalletPage'
import FollowPacksPage from './pages/secondary/FollowPacksPage'
const ROUTES = [
{ path: '/notes', element: <NoteListPage /> },
@ -37,7 +38,8 @@ const ROUTES = [ @@ -37,7 +38,8 @@ const ROUTES = [
{ path: '/settings/translation', element: <TranslationPage /> },
{ path: '/settings/rss-feeds', element: <RssFeedSettingsPage /> },
{ path: '/profile-editor', element: <ProfileEditorPage /> },
{ path: '/mutes', element: <MuteListPage /> }
{ path: '/mutes', element: <MuteListPage /> },
{ path: '/follow-packs', element: <FollowPacksPage /> }
]
export const routes = ROUTES.map(({ path, element }) => ({

Loading…
Cancel
Save