import { useCallback, useEffect, useRef, useState } from 'react'; import pb from '../lib/pocketbase'; /** Per-user record of opened listings, stored in PocketBase so visited listings can be drawn * in a distinct colour on the map and persist across devices. One row per (user, url). */ const CLICKED_LISTINGS_COLLECTION = 'clicked_listings'; function currentUserId(): string | null { return pb.authStore.isValid && pb.authStore.record ? pb.authStore.record.id : null; } /** Tracks which listings the signed-in user has opened. * * Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to * thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in * the map's colour accessor; a fresh Set instance is produced on every change so React re-renders. * Note: a Set can NOT be used directly as a deck.gl `updateTrigger` — deck diffs triggers with * `compareProps`, which finds no enumerable keys on a Set and reports "no change". Callers must * pass `Array.from(clickedUrls)` (or a version counter) as the trigger instead. * * Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours * the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch. */ export function useClickedListings() { const [clickedUrls, setClickedUrls] = useState>(() => new Set()); const [userId, setUserId] = useState(currentUserId); const userIdRef = useRef(userId); userIdRef.current = userId; // Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is // new, without depending on the async state updater having run yet. const clickedUrlsRef = useRef(clickedUrls); clickedUrlsRef.current = clickedUrls; // Follow login/logout via the shared authStore (also covers cross-tab auth changes). useEffect(() => { const unsubscribe = pb.authStore.onChange(() => setUserId(currentUserId())); return unsubscribe; }, []); // Load the user's previously opened listings whenever the signed-in user changes. Signed-out // users keep an in-memory set for the current session only (nothing to persist to). useEffect(() => { if (!userId) { setClickedUrls(new Set()); return; } let cancelled = false; pb.collection(CLICKED_LISTINGS_COLLECTION) .getFullList({ filter: `user = "${userId}"`, fields: 'url' }) .then((records) => { if (cancelled) return; const urls = records .map((record) => (record as { url?: unknown }).url) .filter((url): url is string => typeof url === 'string'); setClickedUrls(new Set(urls)); }) .catch(() => { // Visited history is a convenience; a failed load just starts the session empty. }); return () => { cancelled = true; }; }, [userId]); const markClicked = useCallback((url: string | undefined | null) => { if (!url || clickedUrlsRef.current.has(url)) return; // unknown or already visited // Optimistic, synchronous local update → the map pin recolours on click. setClickedUrls((prev) => (prev.has(url) ? prev : new Set(prev).add(url))); const uid = userIdRef.current; if (!uid) return; // anonymous: recolour for the session, nothing to persist pb.collection(CLICKED_LISTINGS_COLLECTION) .create({ user: uid, url }) .catch(() => { // Ignore duplicate-index conflicts and transient failures; local state already // reflects the click, and a missed write only means it won't persist to the next visit. }); }, []); return { clickedUrls, markClicked }; }