fix: content filter triggers immediate refetch on change
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
3a469dfa8d
commit
c50c61ff36
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue