Commit 03c30b8c authored by Steven's avatar Steven

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.
parent 1cea9b0a
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useCallback, useMemo, useState } from "react"; import { memo, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { YearCalendar } from "@/components/ActivityCalendar"; import { YearCalendar } from "@/components/ActivityCalendar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; 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 { addMonths, formatMonth, getMonthFromDate, getYearFromDate, setYearAndMonth } from "@/lib/calendar-utils";
import type { MonthNavigatorProps } from "@/types/statistics"; import type { MonthNavigatorProps } from "@/types/statistics";
...@@ -21,7 +21,10 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats ...@@ -21,7 +21,10 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange, activityStats
[visibleMonth], [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 handlePrevMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, -1)), [visibleMonth, onMonthChange]);
const handleNextMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, 1)), [visibleMonth, onMonthChange]); const handleNextMonth = useCallback(() => onMonthChange(addMonths(visibleMonth, 1)), [visibleMonth, onMonthChange]);
......
...@@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; ...@@ -2,6 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { countBy } from "lodash-es"; import { countBy } from "lodash-es";
import { useMemo } from "react"; import { useMemo } from "react";
import type { MemoExplorerContext } from "@/components/MemoExplorer";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemos } from "@/hooks/useMemoQueries"; import { useMemos } from "@/hooks/useMemoQueries";
import { useUserStats } from "@/hooks/useUserQueries"; import { useUserStats } from "@/hooks/useUserQueries";
import type { StatisticsData } from "@/types/statistics"; import type { StatisticsData } from "@/types/statistics";
...@@ -14,66 +16,72 @@ export interface FilteredMemoStats { ...@@ -14,66 +16,72 @@ export interface FilteredMemoStats {
export interface UseFilteredMemoStatsOptions { export interface UseFilteredMemoStatsOptions {
userName?: string; userName?: string;
context?: MemoExplorerContext;
} }
const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD");
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
const { userName } = options; const { userName, context } = options;
const currentUser = useCurrentUser();
// Fetch user stats if userName is provided // home/profile: use backend per-user stats (full tag set, not page-limited)
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName); const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
// Fetch memos for fallback computation (or when userName is not provided) // explore: fetch memos with visibility filter to exclude private content.
const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({}); // 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 data = useMemo(() => {
const loading = isLoadingUserStats || isLoadingMemos; const loading = isLoadingUserStats || isLoadingMemos;
let activityStats: Record<string, number> = {}; let activityStats: Record<string, number> = {};
let tagCount: Record<string, number> = {}; let tagCount: Record<string, number> = {};
// Try to use backend user stats if userName is provided and available if (context === "explore") {
if (userName && userStats) { // Tags and activity stats from visibility-filtered memos (no private content).
// Use activity timestamps from user stats 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) { if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
activityStats = countBy( activityStats = countBy(
userStats.memoDisplayTimestamps userStats.memoDisplayTimestamps
.map((ts) => (ts ? timestampDate(ts) : undefined)) .map((ts) => (ts ? timestampDate(ts) : undefined))
.filter((date): date is Date => date !== 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) { if (userStats.tagCount) {
tagCount = userStats.tagCount; tagCount = userStats.tagCount;
} }
} else if (memosResponse?.memos) { } else if (memosResponse?.memos) {
// Fallback: compute from memos if backend stats not available // archived/fallback: compute from cached memos
// Also used for Explore and Archived contexts const displayDates = memosResponse.memos
const displayTimeList: Date[] = []; .map((memo) => (memo.displayTime ? timestampDate(memo.displayTime) : undefined))
const memos = memosResponse.memos; .filter((date): date is Date => date !== undefined)
.map(toDateString);
for (const memo of memos) { activityStats = countBy(displayDates);
// Collect display timestamps for activity calendar for (const memo of memosResponse.memos) {
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; for (const tag of memo.tags ?? []) {
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; tagCount[tag] = (tagCount[tag] || 0) + 1;
} }
} }
} }
activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))); return { statistics: { activityStats }, tags: tagCount, loading };
} }, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]);
return {
statistics: { activityStats },
tags: tagCount,
loading,
};
}, [userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]);
return data; return data;
}; };
...@@ -49,23 +49,17 @@ const MainLayout = () => { ...@@ -49,23 +49,17 @@ const MainLayout = () => {
} }
}, [location.pathname, context]); }, [location.pathname, context]);
// Determine which user name to use for stats // Determine which user name to use for per-user stats.
// - home: current user (uses backend user stats for normal memos) // - home: current user's stats
// - profile: viewed user (uses backend user stats for normal memos) // - profile: viewed user's stats
// - archived: undefined (compute from cached archived memos, since user stats only includes normal memos) // - archived/explore: no user scope (each handled differently inside the hook)
// - explore: undefined (compute from cached memos)
const statsUserName = useMemo(() => { const statsUserName = useMemo(() => {
if (context === "home") { if (context === "home") return currentUser?.name;
return currentUser?.name; if (context === "profile") return profileUserName;
} else if (context === "profile") { return undefined;
return profileUserName;
}
return undefined; // archived and explore contexts compute from cache
}, [context, currentUser, profileUserName]); }, [context, currentUser, profileUserName]);
// Fetch stats from memo store cache (populated by PagedMemoList) const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName, context });
// For user-scoped contexts, use backend user stats for tags (unaffected by filters)
const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName });
return ( return (
<section className="@container w-full min-h-full flex flex-col justify-start items-center"> <section className="@container w-full min-h-full flex flex-col justify-start items-center">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment