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.
This commit is contained in:
Steven 2026-02-23 13:15:01 +08:00
parent 1cea9b0a78
commit 03c30b8ccb
3 changed files with 55 additions and 50 deletions

View File

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

View File

@ -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<string, number> = {};
let tagCount: Record<string, number> = {};
// 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;
};

View File

@ -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 (
<section className="@container w-full min-h-full flex flex-col justify-start items-center">