84 lines
3.6 KiB
TypeScript
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 };
|
|
}
|