From 03c30b8ccbe5f77435f1fcec1086cf566efcf9d9 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 23 Feb 2026 13:15:01 +0800 Subject: [PATCH] fix(web): fix explore page showing private tags and improve stats hook The explore page sidebar was showing tags from the current user's private memos because the default ListMemos query applies a server-side OR filter (creator_id == X || visibility in [...]), mixing private content in. Fix by using a visibility-scoped ListMemos request in the explore context so private memos are always excluded via the AND'd server auth filter. Also consolidate two always-firing useMemos calls into one context-aware query, unify activity stats computation with countBy across all branches, and extract a toDateString helper to remove duplicated formatting logic. --- .../StatisticsView/MonthNavigator.tsx | 7 +- web/src/hooks/useFilteredMemoStats.ts | 76 ++++++++++--------- web/src/layouts/MainLayout.tsx | 22 ++---- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/web/src/components/StatisticsView/MonthNavigator.tsx b/web/src/components/StatisticsView/MonthNavigator.tsx index bb7910f64..14af7c8e1 100644 --- a/web/src/components/StatisticsView/MonthNavigator.tsx +++ b/web/src/components/StatisticsView/MonthNavigator.tsx @@ -1,10 +1,10 @@ import dayjs from "dayjs"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { memo, useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { YearCalendar } from "@/components/ActivityCalendar"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { useTranslation } from "react-i18next"; import { addMonths, formatMonth, getMonthFromDate, getYearFromDate, setYearAndMonth } from "@/lib/calendar-utils"; import type { MonthNavigatorProps } from "@/types/statistics"; @@ -21,7 +21,10 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats [visibleMonth], ); - const monthLabel = useMemo(() => currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" }), [currentMonth, i18n.language]); + const monthLabel = useMemo( + () => currentMonth.toLocaleString(i18n.language, { year: "numeric", month: "long" }), + [currentMonth, i18n.language], + ); const handlePrevMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, -1)), [visibleMonth, onMonthChange]); const handleNextMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, 1)), [visibleMonth, onMonthChange]); diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts index ee9ea7d09..3711f711b 100644 --- a/web/src/hooks/useFilteredMemoStats.ts +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import dayjs from "dayjs"; import { countBy } from "lodash-es"; import { useMemo } from "react"; +import type { MemoExplorerContext } from "@/components/MemoExplorer"; +import useCurrentUser from "@/hooks/useCurrentUser"; import { useMemos } from "@/hooks/useMemoQueries"; import { useUserStats } from "@/hooks/useUserQueries"; import type { StatisticsData } from "@/types/statistics"; @@ -14,66 +16,72 @@ export interface FilteredMemoStats { export interface UseFilteredMemoStatsOptions { userName?: string; + context?: MemoExplorerContext; } -export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { - const { userName } = options; +const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD"); - // Fetch user stats if userName is provided +export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { + const { userName, context } = options; + const currentUser = useCurrentUser(); + + // home/profile: use backend per-user stats (full tag set, not page-limited) const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName); - // Fetch memos for fallback computation (or when userName is not provided) - const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({}); + // explore: fetch memos with visibility filter to exclude private content. + // ListMemos AND's the request filter with the server's auth filter, so private + // memos are always excluded regardless of backend version. + // other contexts: fetch with default params for the fallback memo-based path. + const exploreVisibilityFilter = currentUser != null ? 'visibility in ["PUBLIC", "PROTECTED"]' : 'visibility in ["PUBLIC"]'; + const memoQueryParams = context === "explore" ? { filter: exploreVisibilityFilter, pageSize: 1000 } : {}; + const { data: memosResponse, isLoading: isLoadingMemos } = useMemos(memoQueryParams); const data = useMemo(() => { const loading = isLoadingUserStats || isLoadingMemos; let activityStats: Record = {}; let tagCount: Record = {}; - // Try to use backend user stats if userName is provided and available - if (userName && userStats) { - // Use activity timestamps from user stats + if (context === "explore") { + // Tags and activity stats from visibility-filtered memos (no private content). + for (const memo of memosResponse?.memos ?? []) { + for (const tag of memo.tags ?? []) { + tagCount[tag] = (tagCount[tag] ?? 0) + 1; + } + } + const displayDates = (memosResponse?.memos ?? []) + .map((memo) => (memo.displayTime ? timestampDate(memo.displayTime) : undefined)) + .filter((date): date is Date => date !== undefined) + .map(toDateString); + activityStats = countBy(displayDates); + } else if (userName && userStats) { + // home/profile: use backend per-user stats if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) { activityStats = countBy( userStats.memoDisplayTimestamps .map((ts) => (ts ? timestampDate(ts) : undefined)) .filter((date): date is Date => date !== undefined) - .map((date) => dayjs(date).format("YYYY-MM-DD")), + .map(toDateString), ); } - // Use tag counts from user stats if (userStats.tagCount) { tagCount = userStats.tagCount; } } else if (memosResponse?.memos) { - // Fallback: compute from memos if backend stats not available - // Also used for Explore and Archived contexts - const displayTimeList: Date[] = []; - const memos = memosResponse.memos; - - for (const memo of memos) { - // Collect display timestamps for activity calendar - const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; - if (displayTime) { - displayTimeList.push(displayTime); - } - // Count tags - if (memo.tags && memo.tags.length > 0) { - for (const tag of memo.tags) { - tagCount[tag] = (tagCount[tag] || 0) + 1; - } + // archived/fallback: compute from cached memos + const displayDates = memosResponse.memos + .map((memo) => (memo.displayTime ? timestampDate(memo.displayTime) : undefined)) + .filter((date): date is Date => date !== undefined) + .map(toDateString); + activityStats = countBy(displayDates); + for (const memo of memosResponse.memos) { + for (const tag of memo.tags ?? []) { + tagCount[tag] = (tagCount[tag] || 0) + 1; } } - - activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); } - return { - statistics: { activityStats }, - tags: tagCount, - loading, - }; - }, [userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]); + return { statistics: { activityStats }, tags: tagCount, loading }; + }, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]); return data; }; diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index 55570641e..b4259816c 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -49,23 +49,17 @@ const MainLayout = () => { } }, [location.pathname, context]); - // Determine which user name to use for stats - // - home: current user (uses backend user stats for normal memos) - // - profile: viewed user (uses backend user stats for normal memos) - // - archived: undefined (compute from cached archived memos, since user stats only includes normal memos) - // - explore: undefined (compute from cached memos) + // Determine which user name to use for per-user stats. + // - home: current user's stats + // - profile: viewed user's stats + // - archived/explore: no user scope (each handled differently inside the hook) const statsUserName = useMemo(() => { - if (context === "home") { - return currentUser?.name; - } else if (context === "profile") { - return profileUserName; - } - return undefined; // archived and explore contexts compute from cache + if (context === "home") return currentUser?.name; + if (context === "profile") return profileUserName; + return undefined; }, [context, currentUser, profileUserName]); - // Fetch stats from memo store cache (populated by PagedMemoList) - // For user-scoped contexts, use backend user stats for tags (unaffected by filters) - const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName }); + const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName, context }); return (