perfect-postcode/frontend/src/hooks/useClickedListings.ts

84 lines
3.6 KiB
TypeScript

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<Set<string>>(() => new Set());
const [userId, setUserId] = useState<string | null>(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 };
}