75 changed files with 1076 additions and 709 deletions
@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
|
||||
import NoteCard from '@/components/NoteCard' |
||||
import { Skeleton } from '@/components/ui/skeleton' |
||||
import { useProfileTimeline } from '@/hooks/useProfileTimeline' |
||||
import { Event } from 'nostr-tools' |
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' |
||||
import { ExtendedKind } from '@/constants' |
||||
|
||||
interface ProfileMediaProps { |
||||
pubkey: string |
||||
topSpace?: number |
||||
searchQuery?: string |
||||
kindFilter?: string |
||||
onEventsChange?: (events: Event[]) => void |
||||
} |
||||
|
||||
const MEDIA_KIND_LIST = [ |
||||
ExtendedKind.PICTURE, |
||||
ExtendedKind.VIDEO, |
||||
ExtendedKind.SHORT_VIDEO, |
||||
ExtendedKind.VOICE, |
||||
ExtendedKind.VOICE_COMMENT |
||||
] |
||||
|
||||
const ProfileMedia = forwardRef<{ refresh: () => void; getEvents: () => Event[] }, ProfileMediaProps>( |
||||
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => { |
||||
const [isRefreshing, setIsRefreshing] = useState(false) |
||||
|
||||
const cacheKey = useMemo(() => `${pubkey}-media`, [pubkey]) |
||||
|
||||
const { events: timelineEvents, isLoading, refresh } = useProfileTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds: MEDIA_KIND_LIST, |
||||
limit: 200 |
||||
}) |
||||
|
||||
useEffect(() => { |
||||
onEventsChange?.(timelineEvents) |
||||
}, [timelineEvents, onEventsChange]) |
||||
|
||||
useEffect(() => { |
||||
if (!isLoading) { |
||||
setIsRefreshing(false) |
||||
} |
||||
}, [isLoading]) |
||||
|
||||
useImperativeHandle( |
||||
ref, |
||||
() => ({ |
||||
refresh: () => { |
||||
setIsRefreshing(true) |
||||
refresh() |
||||
}, |
||||
getEvents: () => timelineEvents |
||||
}), |
||||
[refresh, timelineEvents] |
||||
) |
||||
|
||||
const eventsFilteredByKind = useMemo(() => { |
||||
if (kindFilter === 'all') { |
||||
return timelineEvents |
||||
} |
||||
const kindNumber = parseInt(kindFilter, 10) |
||||
if (Number.isNaN(kindNumber)) { |
||||
return timelineEvents |
||||
} |
||||
return timelineEvents.filter((event) => event.kind === kindNumber) |
||||
}, [timelineEvents, kindFilter]) |
||||
|
||||
const filteredEvents = useMemo(() => { |
||||
if (!searchQuery.trim()) { |
||||
return eventsFilteredByKind |
||||
} |
||||
const query = searchQuery.toLowerCase() |
||||
return eventsFilteredByKind.filter( |
||||
(event) => |
||||
event.content.toLowerCase().includes(query) || |
||||
event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(query)) |
||||
) |
||||
}, [eventsFilteredByKind, searchQuery]) |
||||
|
||||
const getKindLabel = (kindValue: string) => { |
||||
if (!kindValue || kindValue === 'all') return 'media items' |
||||
const kindNum = parseInt(kindValue, 10) |
||||
if (kindNum === ExtendedKind.PICTURE) return 'photos' |
||||
if (kindNum === ExtendedKind.VIDEO) return 'videos' |
||||
if (kindNum === ExtendedKind.SHORT_VIDEO) return 'short videos' |
||||
if (kindNum === ExtendedKind.VOICE) return 'voice posts' |
||||
if (kindNum === ExtendedKind.VOICE_COMMENT) return 'voice comments' |
||||
return 'media' |
||||
} |
||||
|
||||
if (!pubkey) { |
||||
return ( |
||||
<div className="flex justify-center items-center py-8"> |
||||
<div className="text-sm text-muted-foreground">No profile selected</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
if (isLoading && timelineEvents.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 ${getKindLabel(kindFilter)} match your search` |
||||
: `No ${getKindLabel(kindFilter)} found`} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
|
||||
return ( |
||||
<div style={{ marginTop: topSpace || 0 }}> |
||||
{isRefreshing && ( |
||||
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing media...</div> |
||||
)} |
||||
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && ( |
||||
<div className="px-4 py-2 text-sm text-muted-foreground"> |
||||
{filteredEvents.length} of {eventsFilteredByKind.length} {getKindLabel(kindFilter)} |
||||
</div> |
||||
)} |
||||
<div className="space-y-2"> |
||||
{filteredEvents.map((event) => ( |
||||
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
||||
) |
||||
|
||||
ProfileMedia.displayName = 'ProfileMedia' |
||||
|
||||
export default ProfileMedia |
||||
|
||||
@ -0,0 +1,202 @@
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react' |
||||
import { Event } from 'nostr-tools' |
||||
import client from '@/services/client.service' |
||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { normalizeUrl } from '@/lib/url' |
||||
|
||||
type ProfileTimelineCacheEntry = { |
||||
events: Event[] |
||||
lastUpdated: number |
||||
} |
||||
|
||||
const timelineCache = new Map<string, ProfileTimelineCacheEntry>() |
||||
const relayGroupCache = new Map<string, string[][]>() |
||||
|
||||
type UseProfileTimelineOptions = { |
||||
pubkey: string |
||||
cacheKey: string |
||||
kinds: number[] |
||||
limit?: number |
||||
filterPredicate?: (event: Event) => boolean |
||||
} |
||||
|
||||
type UseProfileTimelineResult = { |
||||
events: Event[] |
||||
isLoading: boolean |
||||
refresh: () => void |
||||
} |
||||
|
||||
async function getRelayGroups(pubkey: string): Promise<string[][]> { |
||||
const cached = relayGroupCache.get(pubkey) |
||||
if (cached) { |
||||
return cached |
||||
} |
||||
|
||||
const [relayList, favoriteRelays] = await Promise.all([ |
||||
client.fetchRelayList(pubkey).catch(() => ({ read: [], write: [] })), |
||||
client.fetchFavoriteRelays(pubkey).catch(() => []) |
||||
]) |
||||
|
||||
const groups: string[][] = [] |
||||
|
||||
const normalizeList = (urls?: string[]) => |
||||
Array.from( |
||||
new Set( |
||||
(urls || []) |
||||
.map((url) => normalizeUrl(url)) |
||||
.filter((value): value is string => !!value) |
||||
) |
||||
) |
||||
|
||||
const readRelays = normalizeList(relayList.read) |
||||
if (readRelays.length) { |
||||
groups.push(readRelays) |
||||
} |
||||
|
||||
const writeRelays = normalizeList(relayList.write) |
||||
if (writeRelays.length) { |
||||
groups.push(writeRelays) |
||||
} |
||||
|
||||
const favoriteRelayList = normalizeList(favoriteRelays) |
||||
if (favoriteRelayList.length) { |
||||
groups.push(favoriteRelayList) |
||||
} |
||||
|
||||
const fastReadRelays = normalizeList(FAST_READ_RELAY_URLS) |
||||
if (fastReadRelays.length) { |
||||
groups.push(fastReadRelays) |
||||
} |
||||
|
||||
if (!groups.length) { |
||||
relayGroupCache.set(pubkey, [fastReadRelays]) |
||||
return [fastReadRelays] |
||||
} |
||||
|
||||
relayGroupCache.set(pubkey, groups) |
||||
return groups |
||||
} |
||||
|
||||
function postProcessEvents( |
||||
rawEvents: Event[], |
||||
filterPredicate: ((event: Event) => boolean) | undefined, |
||||
limit: number |
||||
) { |
||||
const dedupMap = new Map<string, Event>() |
||||
rawEvents.forEach((evt) => { |
||||
if (!dedupMap.has(evt.id)) { |
||||
dedupMap.set(evt.id, evt) |
||||
} |
||||
}) |
||||
|
||||
let events = Array.from(dedupMap.values()) |
||||
if (filterPredicate) { |
||||
events = events.filter(filterPredicate) |
||||
} |
||||
events.sort((a, b) => b.created_at - a.created_at) |
||||
return events.slice(0, limit) |
||||
} |
||||
|
||||
export function useProfileTimeline({ |
||||
pubkey, |
||||
cacheKey, |
||||
kinds, |
||||
limit = 200, |
||||
filterPredicate |
||||
}: UseProfileTimelineOptions): UseProfileTimelineResult { |
||||
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) |
||||
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) |
||||
const [isLoading, setIsLoading] = useState(!cachedEntry) |
||||
const [refreshToken, setRefreshToken] = useState(0) |
||||
const subscriptionRef = useRef<() => void>(() => {}) |
||||
|
||||
useEffect(() => { |
||||
let cancelled = false |
||||
const refreshIndex = refreshToken |
||||
|
||||
const subscribe = async () => { |
||||
setIsLoading(!timelineCache.has(cacheKey)) |
||||
try { |
||||
const relayGroups = await getRelayGroups(pubkey) |
||||
if (cancelled) { |
||||
return |
||||
} |
||||
|
||||
const subRequests = relayGroups |
||||
.map((urls) => ({ |
||||
urls, |
||||
filter: { |
||||
authors: [pubkey], |
||||
kinds, |
||||
limit |
||||
} as any |
||||
})) |
||||
.filter((request) => request.urls.length) |
||||
|
||||
if (!subRequests.length) { |
||||
updateCache([]) |
||||
setIsLoading(false) |
||||
return |
||||
} |
||||
|
||||
const { closer } = await client.subscribeTimeline( |
||||
subRequests, |
||||
{ |
||||
onEvents: (fetchedEvents) => { |
||||
if (cancelled) return |
||||
const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) |
||||
timelineCache.set(cacheKey, { |
||||
events: processed, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
setEvents(processed) |
||||
setIsLoading(false) |
||||
}, |
||||
onNew: (evt) => { |
||||
if (cancelled) return |
||||
setEvents((prevEvents) => { |
||||
const combined = [evt as Event, ...prevEvents] |
||||
const processed = postProcessEvents(combined, filterPredicate, limit) |
||||
timelineCache.set(cacheKey, { |
||||
events: processed, |
||||
lastUpdated: Date.now() |
||||
}) |
||||
return processed |
||||
}) |
||||
} |
||||
}, |
||||
{ needSort: true } |
||||
) |
||||
|
||||
subscriptionRef.current = () => closer() |
||||
} catch (error) { |
||||
if (!cancelled) { |
||||
setIsLoading(false) |
||||
} |
||||
} |
||||
} |
||||
|
||||
subscribe() |
||||
|
||||
return () => { |
||||
cancelled = true |
||||
subscriptionRef.current() |
||||
subscriptionRef.current = () => {} |
||||
} |
||||
}, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken]) |
||||
|
||||
const refresh = useCallback(() => { |
||||
subscriptionRef.current() |
||||
subscriptionRef.current = () => {} |
||||
timelineCache.delete(cacheKey) |
||||
setIsLoading(true) |
||||
setRefreshToken((token) => token + 1) |
||||
}, []) |
||||
|
||||
return { |
||||
events, |
||||
isLoading, |
||||
refresh |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue