perf(web): reduce memo feed startup and rendering work
This commit is contained in:
@@ -27,7 +27,6 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { countTasks } from "@/utils/markdown-manipulation";
|
||||
import { useMemoActionHandlers } from "./hooks";
|
||||
import type { MemoActionMenuProps } from "./types";
|
||||
|
||||
@@ -41,10 +40,8 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||
// Derived state
|
||||
const isComment = Boolean(memo.parent);
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
const taskStats = countTasks(memo.content);
|
||||
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
|
||||
const hasOpenTasks = taskStats.completed < taskStats.total;
|
||||
const hasCompletedTasks = taskStats.completed > 0;
|
||||
const canMutateTasks = !readonly && !isArchived && Boolean(memo.property?.hasTaskList);
|
||||
const hasOpenTasks = Boolean(memo.property?.hasIncompleteTasks);
|
||||
|
||||
// Action handlers
|
||||
const {
|
||||
@@ -117,7 +114,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||
<CheckCheckIcon className="w-4 h-auto" />
|
||||
{t("memo.task-actions.check-all")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!hasCompletedTasks} onClick={handleUncheckAllTaskListItemsClick}>
|
||||
<DropdownMenuItem onClick={handleUncheckAllTaskListItemsClick}>
|
||||
<ListRestartIcon className="w-4 h-auto" />
|
||||
{t("memo.task-actions.uncheck-all")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
interface LinkMetadataCardProps {
|
||||
url: string;
|
||||
fallback: React.ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function getHostname(url: string): string {
|
||||
@@ -16,9 +17,9 @@ function getHostname(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => {
|
||||
const LinkMetadataCard = ({ url, fallback, enabled = true }: LinkMetadataCardProps) => {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const { data: metadata, isSuccess } = useLinkMetadata(url);
|
||||
const { data: metadata, isSuccess } = useLinkMetadata(url, { enabled });
|
||||
|
||||
const title = metadata?.title.trim() ?? "";
|
||||
const description = metadata?.description.trim() ?? "";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Element } from "hast";
|
||||
import { type ComponentProps, type ReactNode, Suspense } from "react";
|
||||
import { type ComponentProps, memo, type ReactNode, Suspense } from "react";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
@@ -179,7 +179,7 @@ export const MemoMarkdownRendererCore = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const MemoMarkdownRenderer = (props: MemoMarkdownRendererProps) => {
|
||||
const MemoMarkdownRendererComponent = (props: MemoMarkdownRendererProps) => {
|
||||
if (!hasMathSyntax(props.content)) {
|
||||
return <MemoMarkdownRendererCore {...props} />;
|
||||
}
|
||||
@@ -190,3 +190,18 @@ export const MemoMarkdownRenderer = (props: MemoMarkdownRendererProps) => {
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const haveEqualResolvedMentions = (left: Set<string>, right: Set<string>) => {
|
||||
if (left === right) return true;
|
||||
if (left.size !== right.size) return false;
|
||||
return Array.from(left).every((username) => right.has(username));
|
||||
};
|
||||
|
||||
export const MemoMarkdownRenderer = memo(
|
||||
MemoMarkdownRendererComponent,
|
||||
(previous, next) =>
|
||||
previous.content === next.content &&
|
||||
previous.memoName === next.memoName &&
|
||||
previous.compact === next.compact &&
|
||||
haveEqualResolvedMentions(previous.resolvedMentionUsernames, next.resolvedMentionUsernames),
|
||||
);
|
||||
|
||||
@@ -1,41 +1,105 @@
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
import { userDetailQueryOptions, useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
import { extractUsernameFromName } from "@/lib/resource-names";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
|
||||
const MentionResolutionContext = createContext<Set<string> | null>(null);
|
||||
interface UserResolutionContextValue {
|
||||
mentionUsernamesByContent: ReadonlyMap<string, string[]>;
|
||||
requestedUserNames: ReadonlySet<string>;
|
||||
resolvedMentionUsernames: ReadonlySet<string>;
|
||||
usersByName: ReadonlyMap<string, User | undefined>;
|
||||
}
|
||||
|
||||
const UserResolutionContext = createContext<UserResolutionContextValue | null>(null);
|
||||
const EMPTY_USER_NAMES: string[] = [];
|
||||
|
||||
interface MentionResolutionProviderProps {
|
||||
contents: string[];
|
||||
userNames?: string[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const MentionResolutionProvider = ({ contents, children }: MentionResolutionProviderProps) => {
|
||||
const mentionUsernames = useMemo(() => Array.from(new Set(contents.flatMap((content) => extractMentionUsernames(content)))), [contents]);
|
||||
const { data: mentionUsers } = useUsersByUsernames(mentionUsernames);
|
||||
const resolvedMentionUsernames = useMemo(() => {
|
||||
if (!mentionUsers) {
|
||||
return new Set<string>();
|
||||
export const MentionResolutionProvider = ({ contents, userNames = EMPTY_USER_NAMES, children }: MentionResolutionProviderProps) => {
|
||||
const mentionUsernamesByContent = useMemo(
|
||||
() => new Map(contents.map((content) => [content, extractMentionUsernames(content)] as const)),
|
||||
[contents],
|
||||
);
|
||||
const mentionUsernames = useMemo(
|
||||
() => Array.from(new Set(Array.from(mentionUsernamesByContent.values()).flat())),
|
||||
[mentionUsernamesByContent],
|
||||
);
|
||||
const requestedUserNames = useMemo(() => new Set(userNames.filter(Boolean)), [userNames]);
|
||||
const requestedUsernames = useMemo(
|
||||
() => Array.from(new Set([...mentionUsernames, ...Array.from(requestedUserNames, extractUsernameFromName)])),
|
||||
[mentionUsernames, requestedUserNames],
|
||||
);
|
||||
const { data: usersByUsername } = useUsersByUsernames(requestedUsernames);
|
||||
const value = useMemo<UserResolutionContextValue>(() => {
|
||||
const resolvedMentionUsernames = new Set<string>();
|
||||
const usersByName = new Map<string, User | undefined>();
|
||||
|
||||
for (const [username, user] of usersByUsername ?? []) {
|
||||
if (user) {
|
||||
usersByName.set(user.name, user);
|
||||
if (mentionUsernames.includes(username)) {
|
||||
resolvedMentionUsernames.add(username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
|
||||
}, [mentionUsers]);
|
||||
return { mentionUsernamesByContent, requestedUserNames, resolvedMentionUsernames, usersByName };
|
||||
}, [mentionUsernames, mentionUsernamesByContent, requestedUserNames, usersByUsername]);
|
||||
|
||||
return <MentionResolutionContext.Provider value={resolvedMentionUsernames}>{children}</MentionResolutionContext.Provider>;
|
||||
return <UserResolutionContext.Provider value={value}>{children}</UserResolutionContext.Provider>;
|
||||
};
|
||||
|
||||
export function useResolvedMentionUsernames(usernames: string[]) {
|
||||
const sharedResolvedMentionUsernames = useContext(MentionResolutionContext);
|
||||
const shouldUseSharedResolution = sharedResolvedMentionUsernames !== null;
|
||||
export function useResolvedMentionUsernames(content: string) {
|
||||
const sharedResolution = useContext(UserResolutionContext);
|
||||
const shouldUseSharedResolution = sharedResolution !== null;
|
||||
const usernames = useMemo(
|
||||
() => sharedResolution?.mentionUsernamesByContent.get(content) ?? extractMentionUsernames(content),
|
||||
[content, sharedResolution],
|
||||
);
|
||||
const { data: mentionUsers } = useUsersByUsernames(usernames, { enabled: !shouldUseSharedResolution });
|
||||
|
||||
return useMemo(() => {
|
||||
if (sharedResolvedMentionUsernames) {
|
||||
return sharedResolvedMentionUsernames;
|
||||
if (sharedResolution) {
|
||||
return new Set(usernames.filter((username) => sharedResolution.resolvedMentionUsernames.has(username)));
|
||||
}
|
||||
if (!mentionUsers) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
|
||||
}, [sharedResolvedMentionUsernames, mentionUsers]);
|
||||
}, [mentionUsers, sharedResolution, usernames]);
|
||||
}
|
||||
|
||||
export function useResolvedUser(name: string, options?: { enabled?: boolean }) {
|
||||
const sharedResolution = useContext(UserResolutionContext);
|
||||
const enabled = options?.enabled ?? true;
|
||||
const useSharedResolution = enabled && Boolean(sharedResolution?.requestedUserNames.has(name));
|
||||
const fallbackQueryOptions: ReturnType<typeof userDetailQueryOptions>[] =
|
||||
enabled && !useSharedResolution ? [userDetailQueryOptions(name)] : [];
|
||||
const fallbackQueries = useQueries({ queries: fallbackQueryOptions });
|
||||
|
||||
return useSharedResolution ? sharedResolution?.usersByName.get(name) : fallbackQueries[0]?.data;
|
||||
}
|
||||
|
||||
export function useResolvedUsersByNames(names: string[]) {
|
||||
const sharedResolution = useContext(UserResolutionContext);
|
||||
const useSharedResolution = Boolean(sharedResolution && names.every((name) => sharedResolution.requestedUserNames.has(name)));
|
||||
const uniqueNames = useMemo(() => Array.from(new Set(names)), [names]);
|
||||
const fallbackQueries = useQueries({
|
||||
queries: useSharedResolution ? [] : uniqueNames.map((name) => userDetailQueryOptions(name)),
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (useSharedResolution && sharedResolution) {
|
||||
return new Map(names.map((name) => [name, sharedResolution.usersByName.get(name)] as const));
|
||||
}
|
||||
|
||||
return new Map(uniqueNames.map((name, index) => [name, fallbackQueries[index]?.data] as const));
|
||||
}, [fallbackQueries, names, sharedResolution, uniqueNames, useSharedResolution]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
|
||||
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
|
||||
import type { MemoContentProps } from "./types";
|
||||
@@ -11,8 +10,7 @@ import type { MemoContentProps } from "./types";
|
||||
// since a collapsed card may hide the target).
|
||||
const MemoContent = (props: MemoContentProps) => {
|
||||
const { className, contentClassName, content, onClick, onDoubleClick } = props;
|
||||
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
|
||||
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
|
||||
const resolvedMentionUsernames = useResolvedMentionUsernames(content);
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Element } from "hast";
|
||||
import { useLinkPreviewEnabled } from "@/contexts/ViewContext";
|
||||
import { useNearViewport } from "@/hooks/useNearViewport";
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import LinkMetadataCard from "../LinkMetadataCard";
|
||||
@@ -10,6 +11,11 @@ interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, Rea
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface DeferredLinkPreviewProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function getSingleLinkHref(node?: Element): string | undefined {
|
||||
if (!node || node.tagName !== "p") {
|
||||
return undefined;
|
||||
@@ -45,19 +51,33 @@ export function getSingleLinkHref(node?: Element): string | undefined {
|
||||
return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined;
|
||||
}
|
||||
|
||||
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
|
||||
const { blockDepth } = useMarkdownRenderContext();
|
||||
const linkPreviewEnabled = useLinkPreviewEnabled();
|
||||
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
|
||||
const paragraph = (
|
||||
<p className={cn(markdownStyles.paragraph, className)} {...props}>
|
||||
const DeferredLinkPreview = ({ children, className, href, ...props }: DeferredLinkPreviewProps) => {
|
||||
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLParagraphElement>();
|
||||
const fallback = (
|
||||
<p ref={viewportRef} className={cn(markdownStyles.paragraph, className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
return <LinkMetadataCard url={href} fallback={fallback} enabled={isNearViewport} />;
|
||||
};
|
||||
|
||||
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
|
||||
const { blockDepth } = useMarkdownRenderContext();
|
||||
const linkPreviewEnabled = useLinkPreviewEnabled();
|
||||
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
|
||||
|
||||
if (href) {
|
||||
return <LinkMetadataCard url={href} fallback={paragraph} />;
|
||||
return (
|
||||
<DeferredLinkPreview href={href} className={className} {...props}>
|
||||
{children}
|
||||
</DeferredLinkPreview>
|
||||
);
|
||||
}
|
||||
|
||||
return paragraph;
|
||||
return (
|
||||
<p className={cn(markdownStyles.paragraph, className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,19 +11,27 @@ interface Props {
|
||||
features?: MemoExplorerFeatures;
|
||||
statisticsData: StatisticsData;
|
||||
tagCount: Record<string, number>;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const MemoExplorerDrawer = (props: Props) => {
|
||||
const { context, features, statisticsData, tagCount } = props;
|
||||
const { context, features, statisticsData, tagCount, onOpenChange } = props;
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
onOpenChange?.(false);
|
||||
}, [location.pathname, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
}}
|
||||
>
|
||||
<SheetTrigger render={<Button variant="ghost" />}>
|
||||
<MenuIcon className="size-5 text-foreground" />
|
||||
</SheetTrigger>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren, Ref } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SectionHeader, { type SectionHeaderTab } from "./SectionHeader";
|
||||
|
||||
@@ -10,11 +10,12 @@ interface MetadataSectionProps extends PropsWithChildren {
|
||||
tabs?: SectionHeaderTab[];
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
rootRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const MetadataSection = ({ icon, title, count, tabs, className, contentClassName, children }: MetadataSectionProps) => {
|
||||
const MetadataSection = ({ icon, title, count, tabs, className, contentClassName, rootRef, children }: MetadataSectionProps) => {
|
||||
return (
|
||||
<div className={cn("w-full overflow-hidden rounded-lg border border-border bg-muted/20", className)}>
|
||||
<div ref={rootRef} className={cn("w-full overflow-hidden rounded-lg border border-border bg-muted/20", className)}>
|
||||
<SectionHeader icon={icon} title={title} count={count} tabs={tabs} />
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LinkIcon, MilestoneIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
|
||||
import { useNearViewport } from "@/hooks/useNearViewport";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import RelationCard from "./RelationCard";
|
||||
@@ -17,16 +18,13 @@ interface RelationListViewProps {
|
||||
function RelationListView({ relations, currentMemoName, parentPage, className }: RelationListViewProps) {
|
||||
const t = useTranslate();
|
||||
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
|
||||
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLDivElement>();
|
||||
|
||||
const { referencing: referencingRelations, referenced: referencedRelations } = useMemo(
|
||||
() => getRelationBuckets(relations, currentMemoName),
|
||||
[relations, currentMemoName],
|
||||
);
|
||||
|
||||
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasBothTabs = referencingRelations.length > 0 && referencedRelations.length > 0;
|
||||
const direction: RelationDirection = hasBothTabs ? activeTab : referencingRelations.length > 0 ? "referencing" : "referenced";
|
||||
const isReferencing = direction === "referencing";
|
||||
@@ -40,10 +38,15 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
|
||||
}),
|
||||
[activeRelations, direction],
|
||||
);
|
||||
const resolvedMemos = useResolvedRelationMemos(activeMemoNames);
|
||||
const resolvedMemos = useResolvedRelationMemos(activeMemoNames, { enabled: isNearViewport });
|
||||
|
||||
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MetadataSection
|
||||
rootRef={viewportRef}
|
||||
className={className}
|
||||
icon={icon}
|
||||
title={isReferencing ? t("common.referencing") : t("common.referenced-by")}
|
||||
|
||||
@@ -4,16 +4,17 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { findMemoInCollectionQueries, memoDetailQueryOptions } from "@/hooks/useMemoQueries";
|
||||
import { MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export const useResolvedRelationMemos = (memoNames: string[]) => {
|
||||
export const useResolvedRelationMemos = (memoNames: string[], options?: { enabled?: boolean }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [resolvedMemos, setResolvedMemos] = useState<Record<string, MemoRelation_Memo>>({});
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const missingMemoNames = useMemo(() => {
|
||||
return Array.from(new Set(memoNames)).filter((name) => name && !resolvedMemos[name]);
|
||||
}, [memoNames, resolvedMemos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (missingMemoNames.length === 0) {
|
||||
if (!enabled || missingMemoNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ export const useResolvedRelationMemos = (memoNames: string[]) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [missingMemoNames, queryClient]);
|
||||
}, [enabled, missingMemoNames, queryClient]);
|
||||
|
||||
return resolvedMemos;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useResolvedUsersByNames } from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { useUsersByNames } from "@/hooks/useUserQueries";
|
||||
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ReactionGroup = Map<string, User[]>;
|
||||
|
||||
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
|
||||
const creatorNames = useMemo(() => reactions.map((r) => r.creator), [reactions]);
|
||||
const { data: userMap } = useUsersByNames(creatorNames);
|
||||
const userMap = useResolvedUsersByNames(creatorNames);
|
||||
|
||||
return useMemo(() => {
|
||||
const reactionGroup = new Map<string, User[]>();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type ComponentType, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ComponentType, memo, Suspense, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useResolvedUser } from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { loadMemoEditor } from "@/components/MemoEditor/loader";
|
||||
import type { MemoEditorProps } from "@/components/MemoEditor/types";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
import { findTagMetadata } from "@/lib/tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
@@ -28,7 +28,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
|
||||
const currentUser = useCurrentUser();
|
||||
const { userTagsSetting } = useAuth();
|
||||
const creator = useUser(memoData.creator).data;
|
||||
const creator = useResolvedUser(memoData.creator, { enabled: Boolean(showCreator || props.shareImageDialogOpen) });
|
||||
const isArchived = memoData.state === State.ARCHIVED;
|
||||
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
|
||||
const parentPage = parentPageProp || "/";
|
||||
@@ -54,7 +54,13 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
|
||||
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// The card width is only needed by the share-image dialog. Keep feed cards
|
||||
// free of a permanent ResizeObserver and measure only while that dialog is open.
|
||||
useLayoutEffect(() => {
|
||||
if (!props.shareImageDialogOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = cardRef.current;
|
||||
if (!card) {
|
||||
return;
|
||||
@@ -79,7 +85,7 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
|
||||
resizeObserver.observe(card);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
}, [props.shareImageDialogOpen]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MemoPreview } from "@/components/MemoPreview";
|
||||
import { useMemoComments } from "@/hooks/useMemoQueries";
|
||||
import { useNearViewport } from "@/hooks/useNearViewport";
|
||||
import { useUsersByNames } from "@/hooks/useUserQueries";
|
||||
import { extractMemoIdFromName } from "@/lib/resource-names";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
||||
@@ -9,8 +10,12 @@ import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
||||
const MemoCommentListView: React.FC = () => {
|
||||
const { memo } = useMemoViewContext();
|
||||
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
|
||||
const { ref: viewportRef, isNearViewport } = useNearViewport<HTMLDivElement>();
|
||||
|
||||
const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0, pageSize: 3 });
|
||||
const { data } = useMemoComments(memo.name, {
|
||||
enabled: isNearViewport && !isInMemoDetailPage && commentAmount > 0,
|
||||
pageSize: 3,
|
||||
});
|
||||
const comments = data?.memos ?? [];
|
||||
const displayedComments = comments.slice(0, 3);
|
||||
const { data: commentCreators } = useUsersByNames(displayedComments.map((comment) => comment.creator));
|
||||
@@ -20,7 +25,7 @@ const MemoCommentListView: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-t-0 border-border rounded-b-lg px-4 pt-2 pb-3 flex flex-col gap-1">
|
||||
<div ref={viewportRef} className="border border-t-0 border-border rounded-b-lg px-4 pt-2 pb-3 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-muted-foreground">Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}</span>
|
||||
<Link
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
@@ -46,17 +48,21 @@ interface Props {
|
||||
}
|
||||
|
||||
function useAutoFetchWhenNotScrollable({
|
||||
enabled,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
memoCount,
|
||||
onFetchNext,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
hasNextPage: boolean | undefined;
|
||||
isFetchingNextPage: boolean;
|
||||
memoCount: number;
|
||||
onFetchNext: () => Promise<unknown>;
|
||||
}) {
|
||||
const autoFetchTimeoutRef = useRef<number | null>(null);
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const isPageScrollable = useCallback(() => {
|
||||
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
||||
@@ -70,22 +76,31 @@ function useAutoFetchWhenNotScrollable({
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const shouldFetch = !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
|
||||
const shouldFetch = enabledRef.current && !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
|
||||
|
||||
if (shouldFetch) {
|
||||
await onFetchNext();
|
||||
|
||||
autoFetchTimeoutRef.current = window.setTimeout(() => {
|
||||
void checkAndFetchIfNeeded();
|
||||
}, 500);
|
||||
if (enabledRef.current) {
|
||||
autoFetchTimeoutRef.current = window.setTimeout(() => {
|
||||
void checkAndFetchIfNeeded();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
|
||||
}, [enabled, hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingNextPage && memoCount > 0) {
|
||||
if (enabled && !isFetchingNextPage && memoCount > 0) {
|
||||
void checkAndFetchIfNeeded();
|
||||
}
|
||||
}, [memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
|
||||
}, [enabled, memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled && autoFetchTimeoutRef.current) {
|
||||
clearTimeout(autoFetchTimeoutRef.current);
|
||||
autoFetchTimeoutRef.current = null;
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -98,6 +113,8 @@ function useAutoFetchWhenNotScrollable({
|
||||
|
||||
const PagedMemoList = (props: Props) => {
|
||||
const t = useTranslate();
|
||||
const { isInitialized: authInitialized } = useAuth();
|
||||
const { isInitialized: instanceInitialized } = useInstance();
|
||||
const { filters } = useMemoFilterContext();
|
||||
const { maxColumns, compactMode } = useView();
|
||||
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
|
||||
@@ -136,8 +153,11 @@ const PagedMemoList = (props: Props) => {
|
||||
{ enabled: props.enabled ?? true },
|
||||
);
|
||||
|
||||
// Queries can start as soon as routing is unlocked, but memo content stays
|
||||
// hidden until settings that control its presentation have settled.
|
||||
const isDisplayPending = isLoading || !authInitialized || !instanceInitialized;
|
||||
// Only show the spinner once loading exceeds the delay, so fast loads don't flash it.
|
||||
const showLoader = useDelayedFlag(isLoading, LOADING_INDICATOR_DELAY_MS);
|
||||
const showLoader = useDelayedFlag(isDisplayPending, LOADING_INDICATOR_DELAY_MS);
|
||||
|
||||
// Flatten pages into a single array of memos
|
||||
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
|
||||
@@ -152,6 +172,7 @@ const PagedMemoList = (props: Props) => {
|
||||
|
||||
// Auto-fetch hook: fetches more content when page isn't scrollable
|
||||
useAutoFetchWhenNotScrollable({
|
||||
enabled: !isDisplayPending,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
memoCount: sortedMemoList.length,
|
||||
@@ -160,7 +181,7 @@ const PagedMemoList = (props: Props) => {
|
||||
|
||||
// Infinite scroll: fetch more when user scrolls near bottom
|
||||
useEffect(() => {
|
||||
if (!hasNextPage) return;
|
||||
if (isDisplayPending || !hasNextPage) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
|
||||
@@ -171,7 +192,7 @@ const PagedMemoList = (props: Props) => {
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
}, [isDisplayPending, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const leadingContent = props.renderLeading?.({ useGrid });
|
||||
|
||||
@@ -182,6 +203,18 @@ const PagedMemoList = (props: Props) => {
|
||||
|
||||
// Stable reference so MentionResolutionProvider's memo (keyed on the array) actually holds.
|
||||
const contents = useMemo(() => sortedMemoList.map((memo) => memo.content), [sortedMemoList]);
|
||||
const userNames = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
sortedMemoList.flatMap((memo) => [
|
||||
...(props.showCreator ? [memo.creator] : []),
|
||||
...(memo.reactions ?? []).map((reaction) => reaction.creator),
|
||||
]),
|
||||
),
|
||||
),
|
||||
[props.showCreator, sortedMemoList],
|
||||
);
|
||||
|
||||
const emptyPlaceholder =
|
||||
!isFetchingNextPage && !hasNextPage && sortedMemoList.length === 0 ? (
|
||||
@@ -215,11 +248,11 @@ const PagedMemoList = (props: Props) => {
|
||||
);
|
||||
|
||||
const children = (
|
||||
<MentionResolutionProvider contents={contents}>
|
||||
<MentionResolutionProvider contents={contents} userNames={userNames}>
|
||||
<div ref={layoutMeasureRef} className="w-full">
|
||||
<div className={cn("flex flex-col justify-start w-full mx-auto", useGrid ? "max-w-none" : "max-w-2xl")}>
|
||||
{/* During initial load, show the spinner only after the delay; render nothing before then to avoid a flash. */}
|
||||
{isLoading ? (
|
||||
{isDisplayPending ? (
|
||||
showLoader ? (
|
||||
<Loader />
|
||||
) : null
|
||||
|
||||
@@ -17,6 +17,8 @@ interface AuthState {
|
||||
userWebhooksSetting: UserSetting_WebhooksSetting | undefined;
|
||||
userTagsSetting: UserSetting_TagsSetting | undefined;
|
||||
shortcuts: Shortcut[];
|
||||
/** Authentication identity has settled, while user settings may still be loading. */
|
||||
isIdentityInitialized: boolean;
|
||||
isInitialized: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
@@ -37,6 +39,7 @@ const UNAUTHENTICATED_STATE: AuthState = {
|
||||
userWebhooksSetting: undefined,
|
||||
userTagsSetting: undefined,
|
||||
shortcuts: [],
|
||||
isIdentityInitialized: true,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
};
|
||||
@@ -49,6 +52,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
userWebhooksSetting: undefined,
|
||||
userTagsSetting: undefined,
|
||||
shortcuts: [],
|
||||
isIdentityInitialized: false,
|
||||
isInitialized: false,
|
||||
isLoading: true,
|
||||
});
|
||||
@@ -72,7 +76,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
// `initialize` also runs after sign-in, when the previous unauthenticated
|
||||
// state is already marked initialized. Reset the full-readiness flag so
|
||||
// consumers cannot render with the new identity and stale/default settings.
|
||||
setState((prev) => ({ ...prev, isInitialized: false, isLoading: true }));
|
||||
|
||||
// Try to get or refresh the access token.
|
||||
// This handles PWA isolated storage scenarios (e.g., iOS Safari) where localStorage
|
||||
@@ -102,18 +109,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Publish the verified identity immediately so route modules and their
|
||||
// data queries can start while display-sensitive settings are loading.
|
||||
// Memo rendering remains gated on the full `isInitialized` state.
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
currentUser,
|
||||
isIdentityInitialized: true,
|
||||
}));
|
||||
|
||||
queryClient.setQueryData(userKeys.currentUser(), currentUser);
|
||||
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
|
||||
|
||||
const settings = await fetchUserSettings(currentUser.name);
|
||||
|
||||
setState({
|
||||
currentUser,
|
||||
...settings,
|
||||
isIdentityInitialized: true,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Pre-populate React Query cache
|
||||
queryClient.setQueryData(userKeys.currentUser(), currentUser);
|
||||
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize auth:", error);
|
||||
clearAccessToken();
|
||||
|
||||
@@ -28,6 +28,8 @@ const buildInstanceSettingName = (key: InstanceSetting_Key): string => {
|
||||
interface InstanceState {
|
||||
profile: InstanceProfile;
|
||||
settings: InstanceSetting[];
|
||||
/** Instance profile has settled, while non-routing settings may still be loading. */
|
||||
isProfileInitialized: boolean;
|
||||
isInitialized: boolean;
|
||||
isLoading: boolean;
|
||||
// True only when the profile was successfully fetched from the server.
|
||||
@@ -54,6 +56,7 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<InstanceState>({
|
||||
profile: create(InstanceProfileSchema, {}),
|
||||
settings: [],
|
||||
isProfileInitialized: false,
|
||||
isInitialized: false,
|
||||
isLoading: true,
|
||||
profileLoaded: false,
|
||||
@@ -104,31 +107,44 @@ export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
try {
|
||||
const profile = await instanceServiceClient.getInstanceProfile({});
|
||||
|
||||
const settingsResponse = await instanceServiceClient.batchGetInstanceSettings({
|
||||
const profileRequest = instanceServiceClient
|
||||
.getInstanceProfile({})
|
||||
.then((profile) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
profile,
|
||||
isProfileInitialized: true,
|
||||
profileLoaded: true,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to initialize instance profile:", error);
|
||||
setState((prev) => ({ ...prev, isProfileInitialized: true }));
|
||||
});
|
||||
|
||||
const settingsRequest = instanceServiceClient
|
||||
.batchGetInstanceSettings({
|
||||
names: [buildInstanceSettingName(InstanceSetting_Key.GENERAL), buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED)],
|
||||
})
|
||||
.then((settingsResponse) => {
|
||||
for (const setting of settingsResponse.settings) {
|
||||
fetchedSettingsRef.current.add(setting.name);
|
||||
}
|
||||
setState((prev) => ({ ...prev, settings: settingsResponse.settings }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to initialize instance settings:", error);
|
||||
});
|
||||
for (const setting of settingsResponse.settings) {
|
||||
fetchedSettingsRef.current.add(setting.name);
|
||||
}
|
||||
|
||||
setState({
|
||||
profile,
|
||||
settings: settingsResponse.settings,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
profileLoaded: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize instance:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
// Profile and settings are independent. Starting both together removes one
|
||||
// network round trip; the profile can unlock routing before settings settle.
|
||||
await Promise.all([profileRequest, settingsRequest]);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const fetchSettings = useCallback(async (keys: InstanceSetting_Key[]) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface FilteredMemoStats {
|
||||
export interface UseFilteredMemoStatsOptions {
|
||||
userName?: string;
|
||||
context?: MemoExplorerContext;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD");
|
||||
@@ -35,12 +36,12 @@ const timestampsForBasis = (stats: UserStats, basis: MemoTimeBasis) => {
|
||||
};
|
||||
|
||||
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
|
||||
const { userName, context } = options;
|
||||
const { userName, context, enabled = true } = options;
|
||||
const currentUser = useCurrentUser();
|
||||
const { timeBasis } = useView();
|
||||
|
||||
// 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, { enabled });
|
||||
// explore/archived: fetch backend grouped stats and aggregate them locally.
|
||||
// ListAllUserStats AND's the request filter with the server's auth filter, so
|
||||
// private memos are not included unless explicitly visible to the current user.
|
||||
@@ -53,7 +54,7 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}):
|
||||
: {};
|
||||
const shouldFetchAllUserStats = context === "explore" || (context === "archived" && !!currentUser?.name);
|
||||
const { data: allUserStats = [], isLoading: isLoadingAllUserStats } = useAllUserStats(allUserStatsRequest, {
|
||||
enabled: shouldFetchAllUserStats,
|
||||
enabled: enabled && shouldFetchAllUserStats,
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type RefObject, useEffect, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_ROOT_MARGIN = "400px 0px";
|
||||
|
||||
interface UseNearViewportOptions {
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
interface UseNearViewportResult<T extends Element> {
|
||||
ref: RefObject<T | null>;
|
||||
isNearViewport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns true once the observed element approaches the viewport. The flag stays
|
||||
* true after the first intersection so callers can safely start one-way work
|
||||
* such as data fetching without cancelling it when the element scrolls away.
|
||||
*/
|
||||
export function useNearViewport<T extends Element>(options: UseNearViewportOptions = {}): UseNearViewportResult<T> {
|
||||
const { rootMargin = DEFAULT_ROOT_MARGIN } = options;
|
||||
const ref = useRef<T>(null);
|
||||
const [isNearViewport, setIsNearViewport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNearViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsNearViewport(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [isNearViewport, rootMargin]);
|
||||
|
||||
return { ref, isNearViewport };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { shortcutServiceClient, userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { buildUserSettingName } from "@/lib/resource-names";
|
||||
import { buildUserSettingName, userNamePrefix } from "@/lib/resource-names";
|
||||
import {
|
||||
type ListAllUserStatsRequest,
|
||||
ListAllUserStatsRequestSchema,
|
||||
@@ -34,7 +34,7 @@ export const userKeys = {
|
||||
byUsernames: (usernames: string[]) => [...userKeys.all, "byUsernames", ...[...usernames].sort()] as const,
|
||||
};
|
||||
|
||||
const userDetailQueryOptions = (name: string) =>
|
||||
export const userDetailQueryOptions = (name: string) =>
|
||||
queryOptions({
|
||||
queryKey: userKeys.detail(name),
|
||||
queryFn: () => userServiceClient.getUser({ name }),
|
||||
@@ -48,7 +48,7 @@ export function useUser(name: string, options?: { enabled?: boolean }) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserStats(username?: string) {
|
||||
export function useUserStats(username?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: username ? userKeys.userStats(username) : userKeys.stats(),
|
||||
queryFn: async () => {
|
||||
@@ -58,7 +58,7 @@ export function useUserStats(username?: string) {
|
||||
const stats = await userServiceClient.getUserStats({ name: username });
|
||||
return stats;
|
||||
},
|
||||
enabled: !!username,
|
||||
enabled: !!username && (options?.enabled ?? true),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,9 +257,9 @@ export function useUpdateUserGeneralSetting(currentUserName?: string) {
|
||||
}
|
||||
|
||||
// Hook to fetch multiple users by names (returns Map<name, User>)
|
||||
export function useUsersByNames(names: string[]) {
|
||||
export function useUsersByNames(names: string[], options?: { enabled?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = names.length > 0;
|
||||
const enabled = (options?.enabled ?? true) && names.length > 0;
|
||||
const uniqueNames = Array.from(new Set(names));
|
||||
|
||||
return useQuery({
|
||||
@@ -296,15 +296,29 @@ export function useUsersByUsernames(usernames: string[], options?: { enabled?: b
|
||||
return useQuery({
|
||||
queryKey: userKeys.byUsernames(uniqueUsernames),
|
||||
queryFn: async () => {
|
||||
const usersByUsername = new Map<string, User>();
|
||||
const missingUsernames: string[] = [];
|
||||
for (const username of uniqueUsernames) {
|
||||
const detailKey = userKeys.detail(`${userNamePrefix}${username}`);
|
||||
const cachedUser = queryClient.getQueryData<User>(detailKey);
|
||||
const cachedState = queryClient.getQueryState(detailKey);
|
||||
const cacheIsFresh = cachedState ? Date.now() - cachedState.dataUpdatedAt < USER_PROFILE_STALE_TIME : false;
|
||||
if (cachedUser && cacheIsFresh) {
|
||||
usersByUsername.set(username, cachedUser);
|
||||
} else {
|
||||
missingUsernames.push(username);
|
||||
}
|
||||
}
|
||||
|
||||
const batches = [];
|
||||
for (let i = 0; i < uniqueUsernames.length; i += BATCH_GET_USERS_LIMIT) {
|
||||
batches.push(uniqueUsernames.slice(i, i + BATCH_GET_USERS_LIMIT));
|
||||
for (let i = 0; i < missingUsernames.length; i += BATCH_GET_USERS_LIMIT) {
|
||||
batches.push(missingUsernames.slice(i, i + BATCH_GET_USERS_LIMIT));
|
||||
}
|
||||
|
||||
const responses = await Promise.all(batches.map((batch) => userServiceClient.batchGetUsers({ usernames: batch })));
|
||||
const users = responses.flatMap((response) => response.users);
|
||||
const usersByUsername = new Map(users.map((user) => [user.username, user] as const));
|
||||
for (const user of users) {
|
||||
usersByUsername.set(user.username, user);
|
||||
queryClient.setQueryData(userKeys.detail(user.name), user);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { MemoExplorerContext } from "@/components/MemoExplorer";
|
||||
import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
@@ -18,9 +20,12 @@ const MAIN_CONTENT_CLASS_NAME = "w-full min-h-full min-w-0 flex-1";
|
||||
|
||||
const MainLayout = () => {
|
||||
const md = useMediaQuery("md");
|
||||
const { isInitialized: authInitialized } = useAuth();
|
||||
const { isInitialized: instanceInitialized } = useInstance();
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
const [profileUserName, setProfileUserName] = useState<string | undefined>();
|
||||
const [mobileExplorerOpen, setMobileExplorerOpen] = useState(false);
|
||||
const showMemoExplorer = location.pathname !== Routes.ABOUT;
|
||||
|
||||
// Determine context based on current route
|
||||
@@ -65,12 +70,20 @@ const MainLayout = () => {
|
||||
return undefined;
|
||||
}, [context, currentUser, profileUserName]);
|
||||
|
||||
const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName, context });
|
||||
// The feed query starts as soon as identity/profile routing is ready. Keep
|
||||
// auxiliary statistics behind full settings initialization, and do not fetch
|
||||
// mobile drawer data until the user actually opens it.
|
||||
const statsEnabled = showMemoExplorer && authInitialized && instanceInitialized && (md || mobileExplorerOpen);
|
||||
const { statistics, tags } = useFilteredMemoStats({ userName: statsUserName, context, enabled: statsEnabled });
|
||||
const memoExplorerProps = { context, statisticsData: statistics, tagCount: tags };
|
||||
|
||||
return (
|
||||
<section className="@container w-full min-h-full flex flex-col justify-start items-center md:flex-row md:items-start">
|
||||
{!md && <MobileHeader>{showMemoExplorer && <MemoExplorerDrawer {...memoExplorerProps} />}</MobileHeader>}
|
||||
{!md && (
|
||||
<MobileHeader>
|
||||
{showMemoExplorer && <MemoExplorerDrawer {...memoExplorerProps} onOpenChange={setMobileExplorerOpen} />}
|
||||
</MobileHeader>
|
||||
)}
|
||||
{md && showMemoExplorer && (
|
||||
<div className={DESKTOP_EXPLORER_CLASS_NAME}>
|
||||
<MemoExplorer className="px-3 py-6" {...memoExplorerProps} />
|
||||
|
||||
@@ -19,6 +19,10 @@ export const extractMemoIdFromName = (name: string) => {
|
||||
return name.split(memoNamePrefix).pop() || "";
|
||||
};
|
||||
|
||||
export const extractUsernameFromName = (name: string) => {
|
||||
return name.startsWith(userNamePrefix) ? name.slice(userNamePrefix.length) : name;
|
||||
};
|
||||
|
||||
export const extractIdentityProviderUidFromName = (name: string) => {
|
||||
return name.split(identityProviderNamePrefix).pop() || "";
|
||||
};
|
||||
|
||||
+6
-3
@@ -25,8 +25,8 @@ applyLocaleEarly();
|
||||
|
||||
// Inner component that initializes contexts
|
||||
function AppInitializer({ children }: { children: React.ReactNode }) {
|
||||
const { isInitialized: authInitialized, initialize: initAuth, currentUser } = useAuth();
|
||||
const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance();
|
||||
const { isIdentityInitialized, initialize: initAuth, currentUser } = useAuth();
|
||||
const { isProfileInitialized, initialize: initInstance } = useInstance();
|
||||
const initStartedRef = useRef(false);
|
||||
|
||||
// Initialize on mount - run in parallel for better performance
|
||||
@@ -48,7 +48,10 @@ function AppInitializer({ children }: { children: React.ReactNode }) {
|
||||
// Live refresh: listen for memo changes via SSE and invalidate caches.
|
||||
useLiveMemoRefresh();
|
||||
|
||||
if (!authInitialized || !instanceInitialized) {
|
||||
// Route loading and feed requests only need the verified identity and the
|
||||
// instance profile. Display-sensitive settings continue in the background;
|
||||
// PagedMemoList keeps memo content hidden until they have settled.
|
||||
if (!isIdentityInitialized || !isProfileInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import MemoEditor from "@/components/MemoEditor";
|
||||
import { deriveDefaultCreateTimeFromFilters } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList, { getMemoKey } from "@/components/PagedMemoList";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||
@@ -15,7 +14,6 @@ import { useTranslate } from "@/utils/i18n";
|
||||
const Home = () => {
|
||||
const user = useCurrentUser();
|
||||
const t = useTranslate();
|
||||
const { isInitialized } = useInstance();
|
||||
const { filters } = useMemoFilterContext();
|
||||
const defaultCreateTime = useMemo(() => deriveDefaultCreateTimeFromFilters(filters), [filters]);
|
||||
|
||||
@@ -40,7 +38,6 @@ const Home = () => {
|
||||
listSort={listSort}
|
||||
orderBy={orderBy}
|
||||
filter={memoFilter}
|
||||
enabled={isInitialized}
|
||||
renderLeading={({ useGrid }) => (
|
||||
<MemoEditor
|
||||
className={useGrid ? undefined : "mb-2"}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { MentionResolutionProvider } from "@/components/MemoContent/MentionResol
|
||||
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useMemoDetailError from "@/hooks/useMemoDetailError";
|
||||
import { useInfiniteMemoComments, useMemo } from "@/hooks/useMemoQueries";
|
||||
@@ -17,6 +19,8 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
|
||||
const MemoDetail = () => {
|
||||
const md = useMediaQuery("md");
|
||||
const { isInitialized: authInitialized } = useAuth();
|
||||
const { isInitialized: instanceInitialized } = useInstance();
|
||||
const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false);
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
@@ -76,7 +80,9 @@ const MemoDetail = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !memo) {
|
||||
// Start the memo and comment requests as soon as routing is unlocked, but do
|
||||
// not expose content before tag-blur and instance display settings settle.
|
||||
if (isLoading || !memo || !authInitialized || !instanceInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -85,6 +91,9 @@ const MemoDetail = () => {
|
||||
? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) }
|
||||
: memo;
|
||||
const mentionResolutionContents = [displayMemo.content, ...comments.map((comment) => comment.content)];
|
||||
const userResolutionNames = Array.from(
|
||||
new Set([displayMemo, ...comments].flatMap((item) => [item.creator, ...(item.reactions ?? []).map((reaction) => reaction.creator)])),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
@@ -93,7 +102,7 @@ const MemoDetail = () => {
|
||||
<MemoDetailSidebarDrawer memo={displayMemo} onShareImageOpen={() => setShareImageDialogOpen(true)} />
|
||||
</MobileHeader>
|
||||
)}
|
||||
<MentionResolutionProvider contents={mentionResolutionContents}>
|
||||
<MentionResolutionProvider contents={mentionResolutionContents} userNames={userResolutionNames}>
|
||||
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-6")}>
|
||||
<div className={cn("w-full md:w-[calc(100%-16.5rem)]")}>
|
||||
{parentMemo && (
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { Navigate, Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath } from "@/utils/auth-redirect";
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
/** Waits for instance settings used by public/auth pages to settle. */
|
||||
export const RequireInstanceInitializationRoute = () => {
|
||||
const { isInitialized } = useInstance();
|
||||
return isInitialized ? <Outlet /> : null;
|
||||
};
|
||||
|
||||
/** Keeps non-feed authenticated pages behind all display-sensitive settings. */
|
||||
export const RequireFullInitializationRoute = () => {
|
||||
const { isInitialized: authInitialized } = useAuth();
|
||||
const { isInitialized: instanceInitialized } = useInstance();
|
||||
return authInitialized && instanceInitialized ? <Outlet /> : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Index-route gate mounted at `/`. Authenticated visitors fall through to the
|
||||
* nested Home page; unauthenticated visitors are redirected to `/explore`,
|
||||
|
||||
+32
-10
@@ -4,7 +4,13 @@ import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import RootLayout from "@/layouts/RootLayout";
|
||||
import { lazyWithReload } from "@/utils/lazy";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards";
|
||||
import {
|
||||
LandingRoute,
|
||||
RequireAuthRoute,
|
||||
RequireFullInitializationRoute,
|
||||
RequireGuestRoute,
|
||||
RequireInstanceInitializationRoute,
|
||||
} from "./guards";
|
||||
import { ROUTES } from "./routes";
|
||||
|
||||
const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
|
||||
@@ -47,11 +53,16 @@ export const routeConfig: RouteObject[] = [
|
||||
// one-time OAuth state. Keep it outside the guest-only subtree.
|
||||
{ path: "callback", element: <AuthCallback /> },
|
||||
{
|
||||
element: <RequireGuestRoute />,
|
||||
element: <RequireInstanceInitializationRoute />,
|
||||
children: [
|
||||
{ path: "", element: <SignIn /> },
|
||||
{ path: "admin", element: <AdminSignIn /> },
|
||||
{ path: "signup", element: <SignUp /> },
|
||||
{
|
||||
element: <RequireGuestRoute />,
|
||||
children: [
|
||||
{ path: "", element: <SignIn /> },
|
||||
{ path: "admin", element: <AdminSignIn /> },
|
||||
{ path: "signup", element: <SignUp /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -68,14 +79,20 @@ export const routeConfig: RouteObject[] = [
|
||||
element: <LandingRoute />,
|
||||
children: [{ index: true, element: <Home /> }],
|
||||
},
|
||||
{ path: Routes.ABOUT, element: <About /> },
|
||||
{
|
||||
element: <RequireInstanceInitializationRoute />,
|
||||
children: [{ path: Routes.ABOUT, element: <About /> }],
|
||||
},
|
||||
{ path: Routes.EXPLORE, element: <Explore /> },
|
||||
{ path: "u/:username", element: <UserProfile /> },
|
||||
{
|
||||
element: <RequireAuthRoute />,
|
||||
children: [
|
||||
{ path: Routes.ARCHIVED, element: <Archived /> },
|
||||
{ path: Routes.SHORTCUTS, element: <Shortcuts /> },
|
||||
{
|
||||
element: <RequireFullInitializationRoute />,
|
||||
children: [{ path: Routes.SHORTCUTS, element: <Shortcuts /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -85,9 +102,14 @@ export const routeConfig: RouteObject[] = [
|
||||
{
|
||||
element: <RequireAuthRoute />,
|
||||
children: [
|
||||
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
|
||||
{ path: Routes.INBOX, element: <Inboxes /> },
|
||||
{ path: Routes.SETTING, element: <Setting /> },
|
||||
{
|
||||
element: <RequireFullInitializationRoute />,
|
||||
children: [
|
||||
{ path: Routes.ATTACHMENTS, element: <Attachments /> },
|
||||
{ path: Routes.INBOX, element: <Inboxes /> },
|
||||
{ path: Routes.SETTING, element: <Setting /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "403", element: <PermissionDenied /> },
|
||||
|
||||
@@ -70,23 +70,6 @@ export function toggleTaskAtIndex(markdown: string, taskIndex: number, checked:
|
||||
return toggleTaskAtLine(markdown, task.lineNumber, checked);
|
||||
}
|
||||
|
||||
export function countTasks(markdown: string): {
|
||||
total: number;
|
||||
completed: number;
|
||||
incomplete: number;
|
||||
} {
|
||||
const tasks = extractTasksFromAst(markdown);
|
||||
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter((t) => t.checked).length;
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
incomplete: total - completed,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTaskLineNumber(markdown: string, taskIndex: number): number {
|
||||
const tasks = extractTasksFromAst(markdown);
|
||||
|
||||
|
||||
@@ -18,7 +18,13 @@ vi.mock("@/contexts/InstanceContext", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({
|
||||
useTranslate: () => (key: string) => (key === "common.version" ? "Version" : key),
|
||||
useTranslate: () => (key: string) =>
|
||||
(
|
||||
{
|
||||
"common.version": "Version",
|
||||
"about.powered-by": "Powered by Memos",
|
||||
} as Record<string, string>
|
||||
)[key] ?? key,
|
||||
}));
|
||||
|
||||
describe("<About>", () => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const authState = vi.hoisted(() => ({ hasToken: false }));
|
||||
const clients = vi.hoisted(() => ({
|
||||
getCurrentUser: vi.fn(),
|
||||
listShortcuts: vi.fn(),
|
||||
listUserSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/auth-state", () => ({
|
||||
clearAccessToken: vi.fn(),
|
||||
getAccessToken: () => (authState.hasToken ? "token" : undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/connect", () => ({
|
||||
authServiceClient: {
|
||||
getCurrentUser: clients.getCurrentUser,
|
||||
signOut: vi.fn(),
|
||||
},
|
||||
refreshAccessToken: vi.fn(async () => undefined),
|
||||
shortcutServiceClient: {
|
||||
listShortcuts: clients.listShortcuts,
|
||||
},
|
||||
userServiceClient: {
|
||||
listUserSettings: clients.listUserSettings,
|
||||
},
|
||||
}));
|
||||
|
||||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const Probe = () => {
|
||||
const { currentUser, initialize, isInitialized } = useAuth();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="initialized">{isInitialized ? "yes" : "no"}</span>
|
||||
<span data-testid="user">{currentUser?.name ?? "none"}</span>
|
||||
<button type="button" onClick={() => void initialize()}>
|
||||
initialize
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
describe("AuthProvider initialization", () => {
|
||||
beforeEach(() => {
|
||||
authState.hasToken = false;
|
||||
clients.getCurrentUser.mockReset();
|
||||
clients.listShortcuts.mockReset();
|
||||
clients.listUserSettings.mockReset();
|
||||
});
|
||||
|
||||
it("resets full readiness while post-sign-in settings are pending", async () => {
|
||||
let resolveSettings!: (value: { settings: [] }) => void;
|
||||
let resolveShortcuts!: (value: { shortcuts: [] }) => void;
|
||||
clients.getCurrentUser.mockResolvedValue({ user: { name: "users/alice", username: "alice" } });
|
||||
clients.listUserSettings.mockImplementation(
|
||||
() => new Promise<{ settings: [] }>((resolve) => (resolveSettings = resolve)),
|
||||
);
|
||||
clients.listShortcuts.mockImplementation(
|
||||
() => new Promise<{ shortcuts: [] }>((resolve) => (resolveShortcuts = resolve)),
|
||||
);
|
||||
|
||||
render(<Probe />, { wrapper });
|
||||
|
||||
// Settle the initial unauthenticated pass; this reproduces the state from
|
||||
// which PasswordSignInForm and AuthCallback invoke initialize again.
|
||||
fireEvent.click(screen.getByRole("button", { name: "initialize" }));
|
||||
await waitFor(() => expect(screen.getByTestId("initialized")).toHaveTextContent("yes"));
|
||||
|
||||
authState.hasToken = true;
|
||||
fireEvent.click(screen.getByRole("button", { name: "initialize" }));
|
||||
await waitFor(() => expect(screen.getByTestId("user")).toHaveTextContent("users/alice"));
|
||||
expect(screen.getByTestId("initialized")).toHaveTextContent("no");
|
||||
|
||||
resolveSettings({ settings: [] });
|
||||
resolveShortcuts({ shortcuts: [] });
|
||||
await waitFor(() => expect(screen.getByTestId("initialized")).toHaveTextContent("yes"));
|
||||
});
|
||||
});
|
||||
@@ -106,4 +106,18 @@ describe("useFilteredMemoStats", () => {
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("disables both statistics queries when deferred", () => {
|
||||
mockUseView.mockReturnValue({
|
||||
timeBasis: "create_time",
|
||||
orderByTimeAsc: false,
|
||||
toggleSortOrder: vi.fn(),
|
||||
setTimeBasis: vi.fn(),
|
||||
});
|
||||
|
||||
renderHook(() => useFilteredMemoStats({ userName: "users/test", context: "explore", enabled: false }), { wrapper });
|
||||
|
||||
expect(useUserStats).toHaveBeenCalledWith("users/test", { enabled: false });
|
||||
expect(useAllUserStats).toHaveBeenCalledWith(expect.anything(), { enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/hooks/useCurrentUser", () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
const initialization = vi.hoisted(() => ({ auth: true, instance: true }));
|
||||
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({ isInitialized: initialization.auth }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/InstanceContext", () => ({
|
||||
useInstance: () => ({ isInitialized: initialization.instance }),
|
||||
}));
|
||||
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
import {
|
||||
LandingRoute,
|
||||
RequireAuthRoute,
|
||||
RequireFullInitializationRoute,
|
||||
RequireGuestRoute,
|
||||
RequireInstanceInitializationRoute,
|
||||
} from "@/router/guards";
|
||||
|
||||
const mockedUseCurrentUser = vi.mocked(useCurrentUser);
|
||||
|
||||
@@ -24,6 +40,43 @@ const LocationProbe = () => {
|
||||
const renderAt = (initialEntry: string, children: ReactNode) =>
|
||||
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
|
||||
|
||||
beforeEach(() => {
|
||||
initialization.auth = true;
|
||||
initialization.instance = true;
|
||||
});
|
||||
|
||||
describe("initialization guards", () => {
|
||||
it("keeps instance-dependent pages hidden until instance settings settle", () => {
|
||||
initialization.instance = false;
|
||||
|
||||
renderAt(
|
||||
"/auth",
|
||||
<Routes>
|
||||
<Route element={<RequireInstanceInitializationRoute />}>
|
||||
<Route path="/auth" element={<div data-testid="instance-ready">ready</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("instance-ready")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps non-feed pages hidden until both contexts fully initialize", () => {
|
||||
initialization.auth = false;
|
||||
|
||||
renderAt(
|
||||
"/setting",
|
||||
<Routes>
|
||||
<Route element={<RequireFullInitializationRoute />}>
|
||||
<Route path="/setting" element={<div data-testid="fully-ready">ready</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("fully-ready")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LandingRoute", () => {
|
||||
it("renders the nested home page for an authenticated visitor at /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoMarkdownRenderer } from "@/components/MemoContent/MemoMarkdownRenderer";
|
||||
import { hasMathSyntax } from "@/components/MemoContent/math";
|
||||
|
||||
vi.mock("@/components/MemoContent/math", () => ({
|
||||
hasMathSyntax: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("<MemoMarkdownRenderer /> memoization", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(hasMathSyntax).mockClear();
|
||||
});
|
||||
|
||||
it("does not parse again when only the resolved mention Set identity changes", () => {
|
||||
const { rerender } = render(
|
||||
<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set(["alice"])} />,
|
||||
);
|
||||
|
||||
rerender(<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set(["alice"])} />);
|
||||
|
||||
expect(hasMathSyntax).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders again when the relevant resolved mentions change", () => {
|
||||
const { rerender } = render(
|
||||
<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set<string>()} />,
|
||||
);
|
||||
|
||||
rerender(<MemoMarkdownRenderer content="Hello @alice" memoName="memos/1" resolvedMentionUsernames={new Set(["alice"])} />);
|
||||
|
||||
expect(hasMathSyntax).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useNearViewport } from "@/hooks/useNearViewport";
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback;
|
||||
let observerOptions: IntersectionObserverInit | undefined;
|
||||
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "";
|
||||
readonly thresholds = [];
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
intersectionCallback = callback;
|
||||
observerOptions = options;
|
||||
}
|
||||
|
||||
disconnect = vi.fn();
|
||||
observe = vi.fn();
|
||||
takeRecords = vi.fn(() => []);
|
||||
unobserve = vi.fn();
|
||||
}
|
||||
|
||||
const Probe = () => {
|
||||
const { ref, isNearViewport } = useNearViewport<HTMLDivElement>();
|
||||
return <div ref={ref}>{isNearViewport ? "near" : "waiting"}</div>;
|
||||
};
|
||||
|
||||
describe("useNearViewport", () => {
|
||||
beforeEach(() => {
|
||||
observerOptions = undefined;
|
||||
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("stays dormant until the target approaches the viewport", () => {
|
||||
render(<Probe />);
|
||||
|
||||
expect(screen.getByText("waiting")).toBeInTheDocument();
|
||||
expect(observerOptions).toEqual({ rootMargin: "400px 0px" });
|
||||
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(screen.getByText("near")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,22 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: false }));
|
||||
const feed = vi.hoisted(() => ({ memos: [] as unknown[] }));
|
||||
const feed = vi.hoisted(() => ({
|
||||
memos: [] as unknown[],
|
||||
hasNextPage: false,
|
||||
fetchNextPage: vi.fn(async () => undefined),
|
||||
}));
|
||||
const readiness = vi.hoisted(() => ({ auth: true, instance: true }));
|
||||
|
||||
vi.mock("@/hooks/useMemoQueries", () => ({
|
||||
useInfiniteMemos: () => ({
|
||||
data: { pages: [{ memos: feed.memos, nextPageToken: "" }] },
|
||||
fetchNextPage: vi.fn(async () => undefined),
|
||||
hasNextPage: false,
|
||||
fetchNextPage: feed.fetchNextPage,
|
||||
hasNextPage: feed.hasNextPage,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
@@ -21,6 +26,14 @@ vi.mock("@/contexts/MemoFilterContext", () => ({
|
||||
useMemoFilterContext: () => ({ filters: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => ({ isInitialized: readiness.auth }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/InstanceContext", () => ({
|
||||
useInstance: () => ({ isInitialized: readiness.instance }),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/ViewContext", () => ({
|
||||
useView: () => view,
|
||||
}));
|
||||
@@ -54,6 +67,37 @@ describe("<PagedMemoList>", () => {
|
||||
view.maxColumns = 1;
|
||||
view.compactMode = false;
|
||||
feed.memos = [];
|
||||
feed.hasNextPage = false;
|
||||
feed.fetchNextPage.mockClear();
|
||||
readiness.auth = true;
|
||||
readiness.instance = true;
|
||||
});
|
||||
|
||||
it("does not render fetched memo content before display settings settle", () => {
|
||||
feed.memos = [memo];
|
||||
readiness.auth = false;
|
||||
const renderer = vi.fn((m: Memo) => <div key={m.name}>{m.content}</div>);
|
||||
|
||||
renderList(renderer);
|
||||
|
||||
expect(renderer).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("hello")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not auto-fetch more pages while display settings are pending", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
feed.memos = [memo];
|
||||
feed.hasNextPage = true;
|
||||
readiness.auth = false;
|
||||
|
||||
renderList();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1000));
|
||||
|
||||
expect(feed.fetchNextPage).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the tile sprite Placeholder for the empty state", () => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MentionResolutionProvider,
|
||||
useResolvedMentionUsernames,
|
||||
useResolvedUser,
|
||||
} from "@/components/MemoContent/MentionResolutionContext";
|
||||
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { useUser, userKeys, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
@@ -84,6 +89,49 @@ describe("query deduplication", () => {
|
||||
expect(clients.getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses cached user details instead of issuing another username batch", async () => {
|
||||
const alice = { name: "users/alice", username: "alice" } as User;
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(userKeys.detail(alice.name), alice);
|
||||
|
||||
const batch = renderHook(() => useUsersByUsernames(["alice"]), { wrapper: createWrapper(queryClient) });
|
||||
await waitFor(() => expect(batch.result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(batch.result.current.data?.get("alice")).toBe(alice);
|
||||
expect(clients.batchGetUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves feed creators and mentions through one shared username batch", async () => {
|
||||
const alice = { name: "users/alice", username: "alice" } as User;
|
||||
const bob = { name: "users/bob", username: "bob" } as User;
|
||||
clients.batchGetUsers.mockResolvedValue({ users: [alice, bob] });
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MentionResolutionProvider contents={["Hello @bob"]} userNames={[alice.name]}>
|
||||
{children}
|
||||
</MentionResolutionProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
creator: useResolvedUser(alice.name),
|
||||
mentions: useResolvedMentionUsernames("Hello @bob"),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.creator).toBe(alice);
|
||||
expect(result.current.mentions.has("bob")).toBe(true);
|
||||
});
|
||||
|
||||
expect(clients.batchGetUsers).toHaveBeenCalledTimes(1);
|
||||
expect(clients.batchGetUsers).toHaveBeenCalledWith({ usernames: ["bob", "alice"] });
|
||||
expect(clients.getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses memo list data and canonical detail queries when resolving relations", async () => {
|
||||
const cachedMemo = { name: "memos/cached", snippet: "Already in the list" } as Memo;
|
||||
const missingMemo = { name: "memos/missing", snippet: "Fetched once" } as Memo;
|
||||
|
||||
@@ -2,7 +2,12 @@ import { isValidElement } from "react";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeConfig, ROUTES } from "@/router";
|
||||
import { RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
import {
|
||||
RequireAuthRoute,
|
||||
RequireFullInitializationRoute,
|
||||
RequireGuestRoute,
|
||||
RequireInstanceInitializationRoute,
|
||||
} from "@/router/guards";
|
||||
|
||||
// Walk the nested route config and find the first route with the given path,
|
||||
// starting from the provided roots. Returns undefined if nothing matches.
|
||||
@@ -48,6 +53,7 @@ describe("router configuration", () => {
|
||||
it("wraps the remaining /auth children in RequireGuestRoute", () => {
|
||||
for (const path of ["", "admin", "signup"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireGuestRoute)).toBe(true);
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireInstanceInitializationRoute)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,6 +63,19 @@ describe("router configuration", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-feed pages behind their full initialization requirements", () => {
|
||||
for (const path of [ROUTES.SHORTCUTS, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(true);
|
||||
}
|
||||
expect(hasAncestorOfType(routeConfig, ROUTES.ABOUT, RequireInstanceInitializationRoute)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves memo feeds available for early queries", () => {
|
||||
for (const path of [ROUTES.EXPLORE, ROUTES.ARCHIVED, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireFullInitializationRoute)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves public pages outside RequireAuthRoute", () => {
|
||||
for (const path of [ROUTES.ABOUT, ROUTES.EXPLORE, "memos/:uid", "memos/shares/:token", "u/:username"]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user