From 075aea7f4468963799e396025a217c810e2cf366 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Wed, 28 Oct 2026 13:11:10 +0700 Subject: [PATCH] perf: lazy content images and targeted crew member queries --- src/components/ImageWithFallback.tsx | 4 +++ src/components/SpotCard.tsx | 2 ++ src/hooks/useCrewNotifications.ts | 23 ++++++++++++----- src/hooks/useCrews.ts | 37 ++++++++++++++++++++++++++++ src/lib/queryPersister.ts | 1 + 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/components/ImageWithFallback.tsx b/src/components/ImageWithFallback.tsx index 3b24ae3..7fb8f22 100644 --- a/src/components/ImageWithFallback.tsx +++ b/src/components/ImageWithFallback.tsx @@ -31,6 +31,8 @@ export function ImageWithFallback({ className, fallback, onError, + loading = 'lazy', + decoding = 'async', ...props }: ImageWithFallbackProps) { const candidates = useMemo(() => imageCandidates(src), [src]); @@ -63,6 +65,8 @@ export function ImageWithFallback({ src={candidates[Math.min(index, candidates.length - 1)]} alt={alt} className={className} + loading={loading} + decoding={decoding} onError={(event) => { if (index + 1 < candidates.length) { setIndex(index + 1); diff --git a/src/components/SpotCard.tsx b/src/components/SpotCard.tsx index c40c2d3..0e5e6a3 100644 --- a/src/components/SpotCard.tsx +++ b/src/components/SpotCard.tsx @@ -78,6 +78,8 @@ export function SpotCard({ spot, status, community }: SpotCardProps) { {spot.title} ) : ( diff --git a/src/hooks/useCrewNotifications.ts b/src/hooks/useCrewNotifications.ts index 66322fe..2d29f95 100644 --- a/src/hooks/useCrewNotifications.ts +++ b/src/hooks/useCrewNotifications.ts @@ -4,7 +4,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites'; import { useIncomingCollectiveJoinRequests } from '@/hooks/useCollectiveJoinRequests'; import { useCrewDismissals } from '@/hooks/useCrewDismissals'; -import { useCrews } from '@/hooks/useCrews'; +import { useCrews, useCrewsByMember } from '@/hooks/useCrews'; import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { SKATE_CREW_KIND, MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types'; @@ -36,7 +36,12 @@ export function useCrewNotifications() { const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey); const { data: collectiveRequestsRaw, isLoading: collectiveLoading } = useIncomingCollectiveJoinRequests(user?.pubkey); - const { data: crews, isLoading: crewsLoading } = useCrews(200); + const { data: memberCrews, isLoading: memberCrewsLoading } = useCrewsByMember(user?.pubkey); + // Only admins with an actual incoming request need the full crew directory + // to resolve which crew it belongs to. Everyone else skips that 200-crew read. + const { data: crews, isLoading: crewsLoading } = useCrews(200, { + enabled: !!joinRequests?.length, + }); // Cross-device-synced set of dismissed invite/request ids. const { data: dismissed } = useCrewDismissals(); @@ -75,14 +80,14 @@ export function useCrewNotifications() { }); const listedCrews = useMemo(() => { - if (!user || !crews || !confirmedCoords) return []; - return crews.filter( + if (!user || !memberCrews || !confirmedCoords) return []; + return memberCrews.filter( (c) => c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() && c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()) && !confirmedCoords.has(`${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}`), ); - }, [user, crews, confirmedCoords]); + }, [user, memberCrews, confirmedCoords]); // Resolve each incoming join-request to its crew (for approval + display). const requestsWithCrew = useMemo(() => { @@ -144,7 +149,13 @@ export function useCrewNotifications() { listedCrews, joinRequests: requestsWithEndorsements, collectiveRequests, - isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading || membershipLoading, + isLoading: + invitesLoading || + joinLoading || + collectiveLoading || + memberCrewsLoading || + crewsLoading || + membershipLoading, }; } diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts index d5c7153..132157d 100644 --- a/src/hooks/useCrews.ts +++ b/src/hooks/useCrews.ts @@ -49,6 +49,43 @@ export function useCrews(limit = 200, options: { includeHidden?: boolean; enable }); } +/** + * Crews that list `pubkey` as a member. One tiny `#p` query instead of pulling + * every crew just to find the few where this user may need to confirm membership. + */ +export function useCrewsByMember(pubkey?: string) { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const hidden = useAdminModeration(); + + return useQuery({ + queryKey: ['skate-crews-member', pubkey, getQueryKeySuffix()], + enabled: !!pubkey, + select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden), + queryFn: async () => { + if (!pubkey) return []; + const signal = AbortSignal.timeout(8000); + const events = await querySkateContent( + [ + { + kinds: [SKATE_CREW_KIND], + '#p': [pubkey], + '#t': ['skatecrew'], + limit: 100, + }, + ], + { signal }, + ); + const crews: SkateCrew[] = []; + for (const event of events) { + const crew = parseSkateCrew(event); + if (crew) crews.push(crew); + } + return crews.sort((a, b) => b.createdAt - a.createdAt); + }, + staleTime: 5 * 60 * 1000, + }); +} + /** * Query crews that belong to a specific Lab scene (builder / photo / coaching). * Filters at the relay level via the crew-focus `t` tag, so The Lab pages can diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts index 35ec48d..04bf4ab 100644 --- a/src/lib/queryPersister.ts +++ b/src/lib/queryPersister.ts @@ -68,6 +68,7 @@ const PERSIST_ALLOW_LIST = new Set([ 'skate-crews', 'skate-crew', 'skate-crews-focus', + 'skate-crews-member', 'shop-directory', 'coaches', 'lab-profiles',