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:
Your Name 2026-01-23 16:49:07 +00:00
parent 3a469dfa8d
commit c50c61ff36
6 changed files with 134 additions and 44 deletions

View File

@ -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

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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 */