fix(audit P2 data): NaN guards (events/tours/zaps), query-key fixes, no array mutation, pure preference updater, zap limit
This commit is contained in:
parent
84f80d8330
commit
2f6c6230f3
11 changed files with 49 additions and 26 deletions
|
|
@ -36,7 +36,7 @@ const blankSignals = (): AccountSignals => ({
|
|||
export function useBatchSpotCommunityScores(
|
||||
spots: Array<{ id: string; pubkey: string; identifier: string }>,
|
||||
) {
|
||||
const { querySkateContent } = useSkateRelays();
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
const refs = useMemo(
|
||||
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
|
||||
|
|
@ -49,7 +49,7 @@ export function useBatchSpotCommunityScores(
|
|||
}, [spots]);
|
||||
|
||||
return useQuery<Map<string, SpotCommunityMini>>({
|
||||
queryKey: ['batch-spot-community', refs.slice().sort().join(',')],
|
||||
queryKey: ['batch-spot-community', refs.slice().sort().join(','), getQueryKeySuffix()],
|
||||
enabled: refs.length > 0,
|
||||
staleTime: 2 * 60 * 1000,
|
||||
queryFn: async (c) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function useBatchTrustScores(pubkeys: string[]) {
|
|||
const { user } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['batch-trust-scores', pubkeys.sort().join(','), user?.pubkey],
|
||||
queryKey: ['batch-trust-scores', [...pubkeys].sort().join(','), user?.pubkey],
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||
const scores = new Map<string, number>();
|
||||
|
|
|
|||
|
|
@ -174,8 +174,14 @@ function fillMonthGaps(keys: string[]): string[] {
|
|||
|
||||
// ─── Hook ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useGrowthStats() {
|
||||
// Community stats read from the CANONICAL community relay (goskatespots) only,
|
||||
/** Stable, short hash of a string — keeps a query key small with many hidden items. */
|
||||
function tinyHash(input: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) | 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
export function useGrowthStats() { // Community stats read from the CANONICAL community relay (goskatespots) only,
|
||||
// so the numbers always match the authoritative community dataset — not a
|
||||
// blend of the user's personal relays that would drift per-device.
|
||||
const { queryCanonical } = useSkateRelays();
|
||||
|
|
@ -183,7 +189,11 @@ export function useGrowthStats() {
|
|||
// it, so a spot the community removed can't inflate the numbers. The moderation
|
||||
// set is part of the query key so the stats recompute once the labels load.
|
||||
const hidden = useAdminModeration();
|
||||
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}`;
|
||||
// Sig must change when the SET changes, not just its size — hiding one item
|
||||
// while unhiding another (same count) must still recompute the stats.
|
||||
const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}:${tinyHash(
|
||||
`${[...hidden.coords].sort().join(',')}#${[...hidden.ids].sort().join(',')}`,
|
||||
)}`;
|
||||
|
||||
return useQuery<GrowthStats>({
|
||||
queryKey: ['growth-stats', 'canonical', hiddenSig],
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ function useMyRsvpCoords(): Set<string> {
|
|||
const { user } = useCurrentUser();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['my-rsvp-coords', user?.pubkey, getQueryKeySuffix()],
|
||||
queryKey: ['my-rsvp-coords', 'reminders', user?.pubkey, getQueryKeySuffix()],
|
||||
enabled: !!user?.pubkey,
|
||||
staleTime: 60_000,
|
||||
queryFn: async (c): Promise<string[]> => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export function useNewsletterIssues(limit = 30) {
|
|||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['newsletter-issues', getQueryKeySuffix()],
|
||||
queryKey: ['newsletter-issues', limit, getQueryKeySuffix()],
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
queryFn: async (c): Promise<SkateNewsletterIssue[]> => {
|
||||
|
|
|
|||
|
|
@ -59,13 +59,13 @@ export function usePreferences() {
|
|||
const setPreferences = useCallback(
|
||||
(patch: Partial<Omit<Preferences, 'updatedAt'>>) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
setLocal((prev) => {
|
||||
const next: Preferences = { ...prev, ...patch, updatedAt: now };
|
||||
void synced.patch(next);
|
||||
return next;
|
||||
});
|
||||
// Compute OUTSIDE the updater: a state updater must be pure (React may run
|
||||
// it twice), and `synced.patch` publishes an encrypted event.
|
||||
const next: Preferences = { ...local, ...patch, updatedAt: now };
|
||||
setLocal(next);
|
||||
void synced.patch(next);
|
||||
},
|
||||
[setLocal, synced],
|
||||
[local, setLocal, synced],
|
||||
);
|
||||
|
||||
return { preferences: local, setPreferences };
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function useBatchSellerSales(pubkeys: string[]) {
|
|||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['batch-seller-sales', pubkeys.sort().join(','), getQueryKeySuffix()],
|
||||
queryKey: ['batch-seller-sales', [...pubkeys].sort().join(','), getQueryKeySuffix()],
|
||||
queryFn: async () => {
|
||||
if (pubkeys.length === 0) return new Map<string, number>();
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export function useSkateRelays() {
|
|||
*/
|
||||
const getQueryKeySuffix = (): string => {
|
||||
const relays = getReadRelays();
|
||||
return relays.sort().join(',');
|
||||
return [...relays].sort().join(',');
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -124,11 +124,11 @@ export function communityLevel(breakdown: SpotCommunityBreakdown, score: number)
|
|||
export function useSpotCommunityScore(
|
||||
spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined,
|
||||
) {
|
||||
const { querySkateContent } = useSkateRelays();
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const coord = spot ? `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}` : undefined;
|
||||
|
||||
const query = useQuery<NostrEvent[]>({
|
||||
queryKey: ['spot-community-score', coord, spot?.id],
|
||||
queryKey: ['spot-community-score', coord, spot?.id, getQueryKeySuffix()],
|
||||
enabled: !!spot && !!coord,
|
||||
staleTime: 2 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export function useZaps(
|
|||
const events = await nostr.query([{
|
||||
kinds: [9735],
|
||||
'#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`],
|
||||
limit: 500,
|
||||
}], { signal });
|
||||
return events;
|
||||
} else {
|
||||
|
|
@ -68,6 +69,7 @@ export function useZaps(
|
|||
const events = await nostr.query([{
|
||||
kinds: [9735],
|
||||
'#e': [actualTarget.id],
|
||||
limit: 500,
|
||||
}], { signal });
|
||||
return events;
|
||||
}
|
||||
|
|
@ -92,9 +94,12 @@ export function useZaps(
|
|||
// Method 1: amount tag (from zap request, sometimes copied to receipt)
|
||||
const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1];
|
||||
if (amountTag) {
|
||||
const millisats = parseInt(amountTag);
|
||||
sats += Math.floor(millisats / 1000);
|
||||
return;
|
||||
const millisats = Number(amountTag);
|
||||
if (Number.isFinite(millisats) && millisats > 0) {
|
||||
sats += Math.floor(millisats / 1000);
|
||||
return;
|
||||
}
|
||||
// Malformed amount — fall through to the bolt11 method.
|
||||
}
|
||||
|
||||
// Method 2: Extract from bolt11 invoice
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,11 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
|
|||
|
||||
if (!d || !title || !start || !hasSkateboardingTag) return null;
|
||||
|
||||
// Reject a malformed timestamp — NaN in `start` poisons sorting and date
|
||||
// rendering for the whole feed.
|
||||
const startNum = Number(start);
|
||||
if (!Number.isFinite(startNum) || startNum <= 0) return null;
|
||||
|
||||
// Validate title length
|
||||
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
|
|
@ -1039,8 +1044,8 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
|
|||
identifier: d,
|
||||
title: title.slice(0, MAX_TITLE_LEN),
|
||||
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
start: parseInt(start),
|
||||
end: end ? parseInt(end) : undefined,
|
||||
start: startNum,
|
||||
end: end && Number.isFinite(Number(end)) ? Number(end) : undefined,
|
||||
geohash,
|
||||
location,
|
||||
image,
|
||||
|
|
@ -3252,13 +3257,16 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null {
|
|||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
const meetingPoint = event.tags.find(([name]) => name === 'meeting-point')?.[1];
|
||||
const meetingCoordsTag = event.tags.find(([name]) => name === 'meeting-point-coords');
|
||||
const meetingPointCoords = meetingCoordsTag
|
||||
? { lat: parseFloat(meetingCoordsTag[1]), lon: parseFloat(meetingCoordsTag[2]) }
|
||||
const mpLat = Number(meetingCoordsTag?.[1]);
|
||||
const mpLon = Number(meetingCoordsTag?.[2]);
|
||||
const meetingPointCoords = meetingCoordsTag && Number.isFinite(mpLat) && Number.isFinite(mpLon)
|
||||
? { lat: mpLat, lon: mpLon }
|
||||
: undefined;
|
||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||
|
||||
const durationTag = event.tags.find(([name]) => name === 'duration');
|
||||
const duration = durationTag ? parseFloat(durationTag[1]) : undefined;
|
||||
const durationNum = durationTag ? Number(durationTag[1]) : undefined;
|
||||
const duration = durationNum !== undefined && Number.isFinite(durationNum) ? durationNum : undefined;
|
||||
const durationUnit = durationTag?.[2] as SkateTour['durationUnit'] || 'hours';
|
||||
|
||||
const skillLevel = event.tags.find(([name]) => name === 'skill-level')?.[1] as SkateTour['skillLevel'];
|
||||
|
|
|
|||
Loading…
Reference in a new issue