diff --git a/src/components/EventCountdown.tsx b/src/components/EventCountdown.tsx
index b7f8bca..fcece49 100644
--- a/src/components/EventCountdown.tsx
+++ b/src/components/EventCountdown.tsx
@@ -61,9 +61,9 @@ export function EventCountdown({
return () => clearInterval(id);
}, []);
- // `tick` is a dependency so the label actually recomputes on each interval.
+ // `tick` forces a periodic recompute; `t` so a language switch updates it.
// eslint-disable-next-line react-hooks/exhaustive-deps
- const info = useMemo(() => computeStatus(start, end, t), [start, end, tick]);
+ const info = useMemo(() => computeStatus(start, end, t), [start, end, tick, t]);
const styles: Record = {
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse',
diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx
index 8b27483..581af2d 100644
--- a/src/components/ListingTradeActions.tsx
+++ b/src/components/ListingTradeActions.tsx
@@ -86,11 +86,13 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const iConfirmedTrade = !!trade?.iConfirmed;
const iRequestedConfirmation = !!requestState?.iRequested;
+ const isExpired = !!item.expiration && item.expiration < Math.floor(Date.now() / 1000);
const canBuy =
!!user &&
!isOwnListing &&
!isWTB &&
!isSold &&
+ !isExpired &&
!iConfirmedTrade &&
item.priceType === 'fixed' &&
typeof buyAmountSats === 'number' &&
diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx
index 24ae71f..ce59be1 100644
--- a/src/components/RouteDialog.tsx
+++ b/src/components/RouteDialog.tsx
@@ -706,7 +706,7 @@ export function RouteDialog({
setCachedAge(null);
setRoute(result);
setTransitIndex(0);
- if (mode !== 'transit') saveRoute(dest, mode, result);
+ if (mode !== 'transit') saveRoute(effectiveOrigin, dest, mode, result);
const map = mapRef.current;
if (map) {
@@ -754,7 +754,7 @@ export function RouteDialog({
// Offline / server down: fall back to the last route we saved for this
// destination + mode, clearly labelled, instead of a dead dialog.
if (mode !== 'transit') {
- const hit = loadRoute(dest, mode);
+ const hit = loadRoute(effectiveOrigin, dest, mode);
if (hit) {
setRoute(hit.route);
setCachedAge(hit.ageMs);
diff --git a/src/components/SponsorSlot.tsx b/src/components/SponsorSlot.tsx
index 33a09cc..e99abe4 100644
--- a/src/components/SponsorSlot.tsx
+++ b/src/components/SponsorSlot.tsx
@@ -15,6 +15,7 @@
*/
import { cn } from '@/lib/utils';
+import { sanitizeUrl } from '@/lib/sanitizeUrl';
export interface Sponsor {
name: string;
@@ -106,6 +107,10 @@ export function SponsorSlot({
// Individual sponsor display
function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'card' | 'inline' }) {
+ // SECURITY: sponsor data comes from event tags (a tour author controls it),
+ // so sanitize before use as an href/src — same as EventSponsors.
+ const safeUrl = sponsor.url ? sanitizeUrl(sponsor.url) : null;
+ const safeLogo = sponsor.logo ? sanitizeUrl(sponsor.logo) : null;
const content = (
- {sponsor.logo && (
+ {safeLogo && (

{sponsor.name}
@@ -140,10 +145,10 @@ function SponsorItem({ sponsor, size }: { sponsor: Sponsor; size: 'banner' | 'ca
);
- if (sponsor.url) {
+ if (safeUrl) {
return (
{
+ // Overpass returns an error/status payload without `elements` on failure;
+ // never assume it exists (a bare `.map` crashed the food layer).
+ const elements: OverpassElement[] = Array.isArray(data?.elements) ? data.elements : [];
+
+ const foodSpots: FoodSpot[] = elements
+ .map((element: OverpassElement): FoodSpot | null => {
const lat = element.lat ?? element.center?.lat;
const lon = element.lon ?? element.center?.lon;
const tags = element.tags ?? {};
@@ -96,7 +100,7 @@ export function useFoodSpots(geohash: string | undefined, radiusKm = 1) {
distance,
};
})
- .filter(Boolean)
+ .filter((s): s is FoodSpot => s !== null)
.sort((a: FoodSpot, b: FoodSpot) => (a.distance ?? Infinity) - (b.distance ?? Infinity));
return foodSpots as FoodSpot[];
diff --git a/src/hooks/useListingRestrictions.ts b/src/hooks/useListingRestrictions.ts
index f16c34d..59915d6 100644
--- a/src/hooks/useListingRestrictions.ts
+++ b/src/hooks/useListingRestrictions.ts
@@ -108,7 +108,12 @@ export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestri
});
// Query user's listings (last 30 days)
- const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000);
+ // Day-granular + memoized: computing this from Date.now() each render changed
+ // the query key continuously and caused a refetch storm.
+ const thirtyDaysAgo = useMemo(
+ () => Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000),
+ [],
+ );
const { data: userListings, isLoading: listingsLoading } = useQuery({
queryKey: ['user-listings', user?.pubkey, thirtyDaysAgo],
queryFn: async (c) => {
diff --git a/src/hooks/useOfflineCache.ts b/src/hooks/useOfflineCache.ts
index ab5ea93..d5e1fd4 100644
--- a/src/hooks/useOfflineCache.ts
+++ b/src/hooks/useOfflineCache.ts
@@ -16,6 +16,7 @@ import {
getPendingPosts,
getPendingPostsCount,
prunePendingPosts,
+ reconcileStalePendingPosts,
removePendingPost,
updatePendingPostStatus,
} from '@/lib/offlineCache';
@@ -201,6 +202,7 @@ export function useSyncPendingPosts() {
if (cancelled || syncingRef.current || globalSyncing) return;
await prunePendingPosts().catch(() => {});
+ await reconcileStalePendingPosts().catch(() => {});
const pending = await getPendingPosts();
if (pending.length === 0) return;
if (cancelled) return;
diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts
index 353ccec..e7b113e 100644
--- a/src/hooks/useSasquatchPro.ts
+++ b/src/hooks/useSasquatchPro.ts
@@ -134,7 +134,7 @@ function grantExpiry(grant: NostrEvent, userPubkey: string): number | null {
const namespace = grant.tags.filter(([n]) => n === 'L').map(([, v]) => v);
if (!namespace.includes(PRO_LABEL_NAMESPACE)) return null;
const value = grant.tags.filter(([n]) => n === 'l').map(([, v]) => v);
- if (value.length && !value.includes(PRO_LABEL_VALUE)) return null;
+ if (!value.includes(PRO_LABEL_VALUE)) return null;
const taggedUser = grant.tags.some(([n, v]) => n === 'p' && v === userPubkey);
if (!taggedUser) return null;
const expiry = Number(tagVal(grant.tags, 'expiry'));
diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts
index 62de3bc..48e5b01 100644
--- a/src/hooks/useSkateRelays.ts
+++ b/src/hooks/useSkateRelays.ts
@@ -6,6 +6,30 @@ import { useCitrine } from '@/hooks/useCitrine';
import { SKATE_RELAY } from '@/lib/types';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
+/**
+ * Deduplicate a batch of events from several relays, keeping the newest per
+ * identity:
+ * - addressable (has `d`): `pubkey:kind:d-tag`;
+ * - replaceable (0, 3, 10000–19999): `pubkey:kind` (NIP-01 keeps only the
+ * latest per author+kind — keying by id would surface stale profiles/relay
+ * lists when relays return different versions);
+ * - otherwise: the event id.
+ */
+function dedupeEvents(events: NostrEvent[]): NostrEvent[] {
+ const map = new Map();
+ for (const event of events) {
+ const dTag = event.tags.find(([name]) => name === 'd')?.[1];
+ let key: string;
+ if (dTag) key = `${event.pubkey}:${event.kind}:${dTag}`;
+ else if (event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000)) {
+ key = `${event.pubkey}:${event.kind}`;
+ } else key = event.id;
+ const existing = map.get(key);
+ if (!existing || event.created_at > existing.created_at) map.set(key, event);
+ }
+ return Array.from(map.values());
+}
+
/**
* Hook that provides relay-aware query functions for skate-specific content.
*
@@ -120,26 +144,7 @@ export function useSkateRelays() {
const allEvents = results.flat();
- // Deduplicate addressable events by pubkey+kind+d-tag
- const eventMap = new Map();
- for (const event of allEvents) {
- const dTag = event.tags.find(([name]) => name === 'd')?.[1];
- if (dTag) {
- // Addressable event - dedupe by coordinate
- const key = `${event.pubkey}:${event.kind}:${dTag}`;
- const existing = eventMap.get(key);
- if (!existing || event.created_at > existing.created_at) {
- eventMap.set(key, event);
- }
- } else {
- // Regular event - dedupe by id
- eventMap.set(event.id, event);
- }
- }
-
- const uniqueEvents = Array.from(eventMap.values());
-
- return uniqueEvents;
+ return dedupeEvents(allEvents);
};
/**
@@ -159,19 +164,7 @@ export function useSkateRelays() {
const relay = nostr.relay(SKATE_RELAY);
const events = await relay.query(filters, { signal: options.signal });
- // Dedupe addressable events by pubkey+kind+d-tag (keep newest).
- const eventMap = new Map();
- for (const event of events) {
- const dTag = event.tags.find(([name]) => name === 'd')?.[1];
- if (dTag) {
- const key = `${event.pubkey}:${event.kind}:${dTag}`;
- const existing = eventMap.get(key);
- if (!existing || event.created_at > existing.created_at) eventMap.set(key, event);
- } else {
- eventMap.set(event.id, event);
- }
- }
- return Array.from(eventMap.values());
+ return dedupeEvents(events);
} catch (err) {
// See querySkateContent: an outage must not look like "empty".
options.onError?.();
diff --git a/src/hooks/useSpotContributions.ts b/src/hooks/useSpotContributions.ts
index 6328a12..be0fc2a 100644
--- a/src/hooks/useSpotContributions.ts
+++ b/src/hooks/useSpotContributions.ts
@@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { SKATE_SPOT_KIND } from '@/lib/types';
+import { useSkateRelays } from '@/hooks/useSkateRelays';
// Kind 1063 = File Metadata (NIP-94) for community photos
export const PHOTO_CONTRIBUTION_KIND = 1063;
@@ -197,7 +198,7 @@ export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string
* own picture is expected, not a community signal.
*/
export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: string }>) {
- const { nostr } = useNostr();
+ const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const refs = useMemo(
() => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`),
@@ -205,12 +206,12 @@ export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: st
);
const query = useQuery({
- queryKey: ['batch-spot-photos', refs.slice().sort().join(',')],
+ queryKey: ['batch-spot-photos', getQueryKeySuffix(), refs.slice().sort().join(',')],
enabled: refs.length > 0,
staleTime: 60_000,
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
- const events = await nostr.query(
+ const events = await querySkateContent(
[{ kinds: [PHOTO_CONTRIBUTION_KIND], '#a': refs, limit: 1000 }],
{ signal },
);
diff --git a/src/hooks/useSpotStatus.ts b/src/hooks/useSpotStatus.ts
index f7d08ad..913a1a5 100644
--- a/src/hooks/useSpotStatus.ts
+++ b/src/hooks/useSpotStatus.ts
@@ -12,6 +12,7 @@ import {
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useSkateRelays } from '@/hooks/useSkateRelays';
/** Reports older than this are ignored entirely (spots do change back). */
const REPORT_MAX_AGE_SECONDS = 180 * 24 * 60 * 60; // 180 days
@@ -173,7 +174,7 @@ export function useSpotStatus(spotPubkey: string, spotIdentifier: string) {
* Returns a Map keyed by spot coordinate (`30818:pubkey:dtag`).
*/
export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: string }>) {
- const { nostr } = useNostr();
+ const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const spotRefs = useMemo(
() => spots.map((s) => spotRefFor(s.pubkey, s.identifier)),
@@ -181,12 +182,12 @@ export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: st
);
const reportsQuery = useQuery({
- queryKey: ['batch-spot-status', spotRefs.slice().sort().join(',')],
+ queryKey: ['batch-spot-status', getQueryKeySuffix(), spotRefs.slice().sort().join(',')],
queryFn: async (c) => {
if (spotRefs.length === 0) return [] as NostrEvent[];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
// Relays index single-letter tags, so '#a' with many refs is one query.
- const events = await nostr.query(
+ const events = await querySkateContent(
[{ kinds: [SPOT_STATUS_KIND], '#a': spotRefs, limit: 1000 }],
{ signal },
);
diff --git a/src/lib/offlineCache.ts b/src/lib/offlineCache.ts
index 03fa160..a742ee1 100644
--- a/src/lib/offlineCache.ts
+++ b/src/lib/offlineCache.ts
@@ -350,6 +350,18 @@ export async function getPendingPostsCount(): Promise {
return db.pendingPosts.count();
}
+/**
+ * Requeue posts left in `publishing` by a crash/reload. Without this they are
+ * never retried (`getPendingPosts` skips `publishing`) yet still counted, so the
+ * action silently disappears while the badge claims it is still pending.
+ */
+export async function reconcileStalePendingPosts(): Promise {
+ const db = getDB();
+ const all = await db.pendingPosts.toArray();
+ const stuck = all.filter((p) => p.status === 'publishing');
+ await Promise.all(stuck.map((p) => db.pendingPosts.update(p.id, { status: 'pending' })));
+}
+
// ============ Encrypted Stash Cache (offline readability) ============
/**
diff --git a/src/lib/routeCache.ts b/src/lib/routeCache.ts
index f4e596d..94ce5a1 100644
--- a/src/lib/routeCache.ts
+++ b/src/lib/routeCache.ts
@@ -26,8 +26,10 @@ interface StoredRoute {
type Store = Record;
-function keyFor(to: { lat: number; lng: number }, mode: string): string {
- return `${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
+function keyFor(from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: string): string {
+ // Origin is part of the key: a route saved from a different start point is not
+ // a valid fallback for the one the user chose (the "Saved route" label would lie).
+ return `${from.lat.toFixed(4)},${from.lng.toFixed(4)}>${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
}
function read(): Store {
@@ -60,13 +62,14 @@ function downsample(coords: Array<[number, number]>, max = MAX_POINTS): Array<[n
/** Remember a freshly-fetched route (best effort). */
export function saveRoute(
+ from: { lat: number; lng: number },
to: { lat: number; lng: number },
mode: string,
route: RouteResult,
): void {
if (!route.coordinates?.length) return;
const store = read();
- store[keyFor(to, mode)] = {
+ store[keyFor(from, to, mode)] = {
at: Date.now(),
distanceM: route.distanceM,
durationS: route.durationS,
@@ -82,10 +85,11 @@ export function saveRoute(
/** Read a cached route for this destination+mode, with how old it is. */
export function loadRoute(
+ from: { lat: number; lng: number },
to: { lat: number; lng: number },
mode: string,
): { route: RouteResult; ageMs: number } | null {
- const stored = read()[keyFor(to, mode)];
+ const stored = read()[keyFor(from, to, mode)];
if (!stored || !Array.isArray(stored.coordinates) || stored.coordinates.length < 2) return null;
return {
route: {
diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx
index 8d67df5..8d3c989 100644
--- a/src/pages/CommunityStatsPage.tsx
+++ b/src/pages/CommunityStatsPage.tsx
@@ -205,11 +205,14 @@ export default function CommunityStatsPage() {
}, [stats, t]);
// Only show last 12 months on charts to keep them readable
- const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
- const recentCumulative = useMemo(() => stats?.cumulativeSpots.slice(-12) ?? [], [stats]);
+ const recentMonthly = useMemo(() => stats?.monthly?.slice(-12) ?? [], [stats]);
+ const recentCumulative = useMemo(() => stats?.cumulativeSpots?.slice(-12) ?? [], [stats]);
// Current month delta — how many items were added this month
- const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]);
+ const thisMonth = useMemo(
+ () => (stats?.monthly?.length ? stats.monthly[stats.monthly.length - 1] : undefined),
+ [stats],
+ );
// Total across ALL Lab creator/service crafts (matches what /lab lists)
const labTotal = useMemo(() => (stats ? labCraftTotal(stats) : 0), [stats]);
@@ -630,11 +633,11 @@ export default function CommunityStatsPage() {
>
- ) : stats && stats.topMappers.length > 0 ? (
+ ) : stats && (stats.topMappers ?? []).length > 0 ? (
<>
- {stats.topMappers.slice(0, 5).map((m, i) => (
+ {(stats.topMappers ?? []).slice(0, 5).map((m, i) => (
))}
diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx
index d6739a4..e04560b 100644
--- a/src/pages/MarketplacePage.tsx
+++ b/src/pages/MarketplacePage.tsx
@@ -1,4 +1,5 @@
import { useSeoMeta } from '@unhead/react';
+import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -192,7 +193,7 @@ export default function MarketplacePage() {
{t('marketUi.marketplace.needBitcoinTitle')}
- {t('marketUi.marketplace.needBitcoinBefore')} {t('marketUi.marketplace.onboardingGuide')} {t('marketUi.marketplace.needBitcoinAfter')}
+ {t('marketUi.marketplace.needBitcoinBefore')} {t('marketUi.marketplace.onboardingGuide')} {t('marketUi.marketplace.needBitcoinAfter')}
@@ -209,12 +210,12 @@ export default function MarketplacePage() {
{t('marketUi.marketplace.ownShopBody')}