75 changed files with 1076 additions and 709 deletions
@ -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 @@ |
|||||||
|
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