From c50c61ff367468c781d6598fa634549936e62588 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Jan 2026 16:49:07 +0000 Subject: [PATCH] fix: content filter triggers immediate refetch on change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix race condition between content filter effect and general filter effect - Map content filters to proper content_type values for backend - Links → link, Image → image/picture/photo, Text → post/system/news_enriched - Live → live/camera/stream, Video → video - Ensure forceFetch fires moveend to trigger refetch Co-Authored-By: Claude Opus 4.5 --- src/api/client.js | 7 ++ src/components/Filters/ContentFilters.jsx | 21 ++-- src/components/Map/postPriority.js | 14 ++- src/components/Map/usePostsEngine.js | 122 +++++++++++++++++----- src/components/Posts/PostDetailModal.jsx | 6 +- src/styles/mapMarkers.css | 8 +- 6 files changed, 134 insertions(+), 44 deletions(-) diff --git a/src/api/client.js b/src/api/client.js index 18e91fa..1c15a5e 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -64,6 +64,11 @@ function invalidateCacheMatching(pattern) { } } +// Invalidate all smart-feed cache entries (called when filter changes) +export function invalidateSmartFeedCache() { + invalidateCacheMatching("smart-feed"); +} + function cloneJson(value) { try { return JSON.parse(JSON.stringify(value)); @@ -513,10 +518,12 @@ export async function fetchSmartFeed(params = {}, { signal } = {}) { } const url = `${apiBase()}/smart-feed?${qs.toString()}`; + console.log('[fetchSmartFeed] URL:', url); try { const data = await fetchJsonCached(url, { ttlMs: 10000, signal }); const items = Array.isArray(data) ? data : data.results || data.items || []; + console.log('[fetchSmartFeed] Got', items.length, 'posts, content_types:', [...new Set(items.map(p => p.content_type))]); return await resolveAssetRefsInPosts(items); } catch (err) { if (err.name === "AbortError") return []; // silently ignore aborted requests diff --git a/src/components/Filters/ContentFilters.jsx b/src/components/Filters/ContentFilters.jsx index 0484aa8..07feb9e 100644 --- a/src/components/Filters/ContentFilters.jsx +++ b/src/components/Filters/ContentFilters.jsx @@ -43,30 +43,29 @@ function isUnknownType(ct) { } // Export filter matching function for reuse +// Filters by content_type field export function matchesContentFilter(post, filter) { if (!filter || filter === "all") return true; const ct = (post.content_type || post.contentType || "").toLowerCase(); - const cat = (post.category || "").toLowerCase(); - const hasVideo = !!(post.video_url || post.videoUrl || post.embed_url || post.embedUrl); - const hasImage = !!(post.image_small || post.image_medium || post.image_large || (post.media_urls && post.media_urls.length > 0)); switch (filter) { case "links": - // News links + unknown types with news category (e.g., systems, news-scrapper) - return cat === "news" || ct === "news" || (isUnknownType(ct) && cat === "news"); + // Link posts (content_type = "link") + return ct === "link"; case "video": - // Recorded videos only + // Video posts (content_type = "video") return ct === "video"; case "image": - return hasImage && !hasVideo && ct !== "camera" && ct !== "video"; + // Image posts (content_type = "image", "picture", "photo") + return ct === "image" || ct === "picture" || ct === "photo"; case "text": - // Text posts + news + unknown types default to text (systems, news-scrapper, etc.) - return ct === "post" || ct === "text" || ct === "blog" || ct === "news" || cat === "friends" || cat === "news" || isUnknownType(ct); + // Text posts: post, system, news_enriched, text, blog, or empty/null + return ct === "post" || ct === "system" || ct === "news_enriched" || ct === "text" || ct === "blog" || ct === ""; case "live": - // Only live content: cameras (quebec511), live streams - NOT recorded videos + // Live content: cameras, live streams (content_type = "camera", "live", "stream") return ct === "live" || ct === "stream" || ct === "camera"; case "cluster": - // Clusters filter shows all posts - the cluster zones overlay them + // Clusters filter shows all posts return true; default: return true; diff --git a/src/components/Map/postPriority.js b/src/components/Map/postPriority.js index 35b5b6b..46c6af6 100644 --- a/src/components/Map/postPriority.js +++ b/src/components/Map/postPriority.js @@ -120,6 +120,7 @@ export function clusterPosts(posts, clusterRadius = 0.001) { export function prioritizePosts(posts, viewCenter, options = {}) { const { maxFullCards = 15, // Max de cartes complètes à afficher (fallback) + maxSimpleMarkers = 50, // Max de marqueurs simples (pins) clusterRadius = 0.001, // Rayon de clustering (~100m) minScoreForCard = 40, // Score minimum pour carte complète (fallback) } = options; @@ -158,9 +159,12 @@ export function prioritizePosts(posts, viewCenter, options = {}) { // If backend provided tiers, use them directly if (hasTiers && fullCardPosts.length > 0) { + // Limit premium cards to maxFullCards and simple markers to maxSimpleMarkers + const limitedFull = fullCardPosts.slice(0, maxFullCards); + const limitedSimple = simpleMarkerPosts.slice(0, maxSimpleMarkers); return { - fullCardPosts, - simpleMarkerPosts, + fullCardPosts: limitedFull, + simpleMarkerPosts: limitedSimple, clusters: new Map(), }; } @@ -232,9 +236,13 @@ export function prioritizePosts(posts, viewCenter, options = {}) { } } + // Limit simple markers to maxSimpleMarkers (default 50) + // Sort by priority to keep best ones + const limitedSimple = finalSimple.slice(0, maxSimpleMarkers); + return { fullCardPosts: finalFull, - simpleMarkerPosts: finalSimple, + simpleMarkerPosts: limitedSimple, clusters, }; } diff --git a/src/components/Map/usePostsEngine.js b/src/components/Map/usePostsEngine.js index 9923986..71cd2fe 100644 --- a/src/components/Map/usePostsEngine.js +++ b/src/components/Map/usePostsEngine.js @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch, resolveAssetRef } from "../../api/client"; +import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch, resolveAssetRef, invalidateSmartFeedCache } from "../../api/client"; import { categoryCode, matchesSubFilter, matchesTimeFilter, effectivePostTime } from "./mapFilter"; import { getCoords, getViewFromMap, haversineKm } from "./mapGeo"; import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"; @@ -409,6 +409,7 @@ export function usePostsEngine({ const replacePoolRef = useRef(false); const retryRef = useRef({ key: "", count: 0, ts: 0 }); const abortControllerRef = useRef(null); + const prevContentFilterRef = useRef(contentFilter); const [mapReady, setMapReady] = useState(false); const clusterModeRef = useRef(false); @@ -452,8 +453,38 @@ export function usePostsEngine({ }, 120); }, [mapReady, mapRef]); + // CRITICAL: Dedicated effect for contentFilter changes (media filter buttons) + // This triggers a forceFetch via moveend when user clicks live/video/image/text/links + useEffect(() => { + const prev = prevContentFilterRef.current; + if (prev === contentFilter) return; // No change + + console.log('[usePostsEngine] CONTENT FILTER CHANGED:', prev, '->', contentFilter); + prevContentFilterRef.current = contentFilter; + + // Clear cache and force refetch + invalidateSmartFeedCache(); + + const map = mapRef.current; + if (!map) return; + + // Clear all posts and trigger refetch + allPostsRef.current = []; + replacePoolRef.current = true; + + // Set forceFetch flag and trigger moveend to refetch with new content filter + map.__swForceFetchAt = Date.now(); + console.log('[usePostsEngine] CONTENT FILTER: triggering forceFetch via moveend'); + setTimeout(() => { + try { + map.fire("moveend"); + } catch {} + }, 50); + }, [contentFilter, mapRef]); + const priorityOpts = { maxFullCards: 15, + maxSimpleMarkers: 50, // Limit simple pins to 50 clusterRadius: 0.002, minScoreForCard: 50, }; @@ -754,9 +785,10 @@ export function usePostsEngine({ let fullCardPosts = []; let simpleMarkerPosts = []; const prioritized = prioritizePosts(visible, viewCenter, { - maxFullCards: 15, // 10-15 cartes complètes selon le zoom - clusterRadius: 0.002, // ~200m clustering radius (augmenté pour plus de groupement) - minScoreForCard: 50, // Score minimum pour carte complète (augmenté de 35 à 50) + maxFullCards: 15, // Exactly 15 premium cards + maxSimpleMarkers: 50, // Exactly 50 simple pins + clusterRadius: 0.002, // ~200m clustering radius + minScoreForCard: 50, }); fullCardPosts = prioritized.fullCardPosts; simpleMarkerPosts = prioritized.simpleMarkerPosts; @@ -845,10 +877,14 @@ export function usePostsEngine({ [mapRef, markersRef, expandedElRef, theme, searchQuery, forceFullPostId, weatherEnabled] ); + // Handle mainFilter/subFilter/timeHours changes (NOT contentFilter - that has its own dedicated effect above) useEffect(() => { + console.log('[usePostsEngine] CATEGORY/TIME FILTER CHANGED: mainFilter=', mainFilter, 'subFilter=', subFilter, 'timeHours=', timeHours); + // Clear frontend cache to ensure fresh data for new filter + invalidateSmartFeedCache(); const map = mapRef.current; replacePoolRef.current = true; - pendingFilterRef.current = `${mainFilter}|${subFilter}|${contentFilter}|${timeHours}`; + pendingFilterRef.current = `${mainFilter}|${subFilter}|${timeHours}`; allPostsRef.current = []; autoWidenRef.current = true; // Prevent auto-widen when user manually changes filter (keep their time choice) if (map && clustersEnabled && heatmapEnabled) { @@ -856,13 +892,16 @@ export function usePostsEngine({ } if (map) { map.__swForceFetchAt = Date.now(); + console.log('[usePostsEngine] CATEGORY/TIME FILTER: triggering forceFetch'); setTimeout(() => { try { map.fire("moveend"); } catch {} }, 60); } - }, [mainFilter, subFilter, contentFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]); + // NOTE: contentFilter is NOT in deps - its changes are handled by the dedicated effect at line ~456 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainFilter, subFilter, timeHours, mapRef, clustersEnabled, heatmapEnabled, updateClusterAreas]); const computeVisible = useCallback( (tf) => { @@ -1015,14 +1054,37 @@ export function usePostsEngine({ let cancelled = false; async function load() { + console.log('[usePostsEngine] load() called, contentFilter=', contentFilter); const mapObj = mapRef.current; - const resolvedView = - viewParams && viewParams.center ? viewParams : getViewFromMap(mapObj); - if (!resolvedView?.center) return; - if ((searchQuery || "").trim()) return; - // If expanded overlay is open: delay fetch (do NOT disturb UI) - if (expandedElRef?.current || mapObj?.__swCenteredOverlay) { + // Check if this is a forced fetch (filter changed) + const forceFetchAt = mapObj?.__swForceFetchAt || 0; + const isFilterChange = forceFetchAt && Date.now() - forceFetchAt < 10000; + + let resolvedView = + viewParams && viewParams.center ? viewParams : getViewFromMap(mapObj); + if (!resolvedView?.center) { + console.log('[usePostsEngine] SKIP: no view center, isFilterChange=', isFilterChange); + // If filter changed but no view, try to get view from map directly + if (isFilterChange && mapObj) { + try { + const center = mapObj.getCenter(); + const zoom = mapObj.getZoom(); + if (center && zoom) { + resolvedView = { center: [center.lng, center.lat], radiusKm: 100 }; + } + } catch (e) {} + } + if (!resolvedView?.center) return; + } + if ((searchQuery || "").trim()) { + console.log('[usePostsEngine] SKIP: searchQuery active:', searchQuery); + return; + } + + // If expanded overlay is open: delay fetch (do NOT disturb UI) - but NOT if filter changed + if (!isFilterChange && (expandedElRef?.current || mapObj?.__swCenteredOverlay)) { + console.log('[usePostsEngine] DELAY: overlay open'); if (delayedFetchRef.current) clearTimeout(delayedFetchRef.current); delayedFetchRef.current = setTimeout(load, 1200); return; @@ -1063,7 +1125,7 @@ export function usePostsEngine({ } catch {} const last = lastFetchRef.current; - const forceFetchAt = mapObj?.__swForceFetchAt || 0; + // forceFetchAt already declared at top of load() for isFilterChange check const forceFetch = forceFetchAt && Date.now() - forceFetchAt < 15000; if (forceFetch) { mapObj.__swForceFetchAt = 0; @@ -1092,7 +1154,10 @@ export function usePostsEngine({ // Cap minMoveKm at 10km so even at large zoom levels we refetch reasonably const minMoveKm = Math.min(10, Math.max(2, radiusKm * 0.03)); - if (!radiusChanged && !boundsChanged && distKm < minMoveKm) return; + if (!radiusChanged && !boundsChanged && distKm < minMoveKm) { + console.log('[usePostsEngine] SKIP: no significant movement', { radiusChanged, boundsChanged, distKm, minMoveKm }); + return; + } // Clear pool if moved significantly (>50% of radius or >100km) if (distKm > Math.max(50, radiusKm * 0.5)) { @@ -1112,27 +1177,29 @@ export function usePostsEngine({ subFilter && subFilter.toUpperCase() !== "ALL" && subFilter !== "All" ? subFilter : ""; // Use smart feed for personalized, trending posts + // Always fetch at least 80 posts to fill 15 premium + 50 pins = 65 minimum const zoom = typeof mapObj?.getZoom === "function" ? mapObj.getZoom() : undefined; - let limit = 50; + let limit = 80; // Minimum 80 to ensure enough posts for 15 premium + 50 pins if (typeof zoom === "number") { - if (zoom <= 6) limit = 90; - else if (zoom <= 8) limit = 70; - else if (zoom <= 10) limit = 60; + if (zoom <= 6) limit = 100; + else if (zoom <= 8) limit = 90; + else if (zoom <= 10) limit = 80; } if (radiusKm > 800) { - limit = Math.max(limit, 80); + limit = Math.max(limit, 100); } if (limit > 120) limit = 120; - // Map contentFilter to content_type for API + // Map contentFilter to content_type for API - ALL filters go to backend let contentTypeParam = undefined; if (contentFilter && contentFilter !== "all" && contentFilter !== "cluster") { if (contentFilter === "live") contentTypeParam = "live,camera,stream"; else if (contentFilter === "video") contentTypeParam = "video"; - else if (contentFilter === "image") contentTypeParam = "image"; - else if (contentFilter === "text") contentTypeParam = "post"; - else if (contentFilter === "links") contentTypeParam = "news"; + else if (contentFilter === "text") contentTypeParam = "post,system,news_enriched,text,blog"; + else if (contentFilter === "image") contentTypeParam = "image,picture,photo"; + else if (contentFilter === "links") contentTypeParam = "link"; } + console.log('[usePostsEngine] FETCH: contentFilter=', contentFilter, 'contentTypeParam=', contentTypeParam); // For "cluster" filter, fetch all posts and filter client-side by clusterPostIds // Abort any previous in-flight request @@ -1246,9 +1313,14 @@ export function usePostsEngine({ clearTimeout(moveDebounceRef.current); moveDebounceRef.current = null; } - if (initialLoadRef.current) { + // Check if filter changed (forceFetch flag set) + const mapObj = mapRef.current; + const forceFetchNow = mapObj?.__swForceFetchAt && Date.now() - mapObj.__swForceFetchAt < 5000; + + if (initialLoadRef.current || forceFetchNow) { initialLoadRef.current = false; - load(); + // Note: flag is cleared inside load() at line ~1095, not here + load(); // Immediate fetch for initial load or filter change } else { moveDebounceRef.current = setTimeout(load, 300); } diff --git a/src/components/Posts/PostDetailModal.jsx b/src/components/Posts/PostDetailModal.jsx index e76eed1..05e44ed 100644 --- a/src/components/Posts/PostDetailModal.jsx +++ b/src/components/Posts/PostDetailModal.jsx @@ -705,9 +705,13 @@ export default function PostDetailModal({ post, onClose, onPostUpdated, onPostDe const handleTruthVote = useCallback(async (vote) => { if (!postId) return; + if (!token) { + window.dispatchEvent(new CustomEvent("sociowire:auth-required")); + return; + } const res = await votePostTruth(postId, vote); if (res) setTruth({ score: res.truth_score ?? res.user_score ?? truth.score, count: res.vote_count ?? truth.count }); - }, [postId, truth]); + }, [postId, truth, token]); const handleComment = useCallback(async () => { const body = commentInput.trim(); diff --git a/src/styles/mapMarkers.css b/src/styles/mapMarkers.css index 44a5bdd..51cc8a3 100644 --- a/src/styles/mapMarkers.css +++ b/src/styles/mapMarkers.css @@ -1251,25 +1251,25 @@ body[data-theme="light"] .sw-modal-x{ .post-pin.sw-appear{ opacity: 0; - transition: opacity 640ms ease; + transition: opacity 800ms ease-out; } .post-pin.sw-appear.sw-appear-in{ opacity: 1; } .post-pin.sw-disappear{ opacity: 0; - transition: opacity 640ms ease; + transition: opacity 700ms ease-out; } .post-pin--simple.sw-appear{ opacity: 0; - transition: opacity 520ms ease; + transition: opacity 650ms ease-out; } .post-pin--simple.sw-appear.sw-appear-in{ opacity: 1; } .post-pin--simple.sw-disappear{ opacity: 0; - transition: opacity 520ms ease; + transition: opacity 600ms ease-out; } /* Cluster main news layout */