memos/web/src/pages/MemoDetail.tsx
boojack 5192732cee fix(web): keep task checkboxes controlled so check-all updates render
remark-gfm passes checked as undefined for unchecked task items, so the
Base UI Checkbox mounted uncontrolled and permanently ignored the
checked=true arriving after "Check all tasks" rewrote the memo content.
Coerce the prop to a boolean so the checkbox is always controlled.

Also drop the `${updateTime}` suffixes from memo React keys and the
comment editor cache key: a protobuf-es Timestamp stringifies to
"[object Object]", so the suffix was a constant — the keys were
effectively name-only already, and now say so honestly.

Fixes usememos/memos#6143
2026-08-06 09:09:37 +08:00

165 lines
6.8 KiB
TypeScript

import { Code, ConnectError } from "@connectrpc/connect";
import { ArrowUpLeftFromCircleIcon } from "lucide-react";
import { useCallback, useEffect, useMemo as useReactMemo, useRef, useState } from "react";
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
import MemoCommentSection from "@/components/MemoCommentSection";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import MemoView from "@/components/MemoView";
import { useAppSidebar } from "@/contexts/AppSidebarContext";
import { useAuth } from "@/contexts/AuthContext";
import { useInstance } from "@/contexts/InstanceContext";
import useMemoDetailError from "@/hooks/useMemoDetailError";
import { useInfiniteMemoComments, useMemo } from "@/hooks/useMemoQueries";
import { useSharedMemo, withShareAttachmentLinks } from "@/hooks/useMemoShareQueries";
import { memoNamePrefix } from "@/lib/resource-names";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const MemoSidebarRegistration = ({
memo,
from,
readonly,
onShareImageOpen,
}: {
memo: Memo;
from?: string;
readonly: boolean;
onShareImageOpen: () => void;
}) => {
const { setMemoDetail } = useAppSidebar();
useEffect(() => {
setMemoDetail({ memo, from, readonly, onShareImageOpen });
}, [from, memo, onShareImageOpen, readonly, setMemoDetail]);
useEffect(() => () => setMemoDetail(undefined), [setMemoDetail]);
return null;
};
const MemoDetail = () => {
const { isInitialized: authInitialized } = useAuth();
const { isInitialized: instanceInitialized } = useInstance();
const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false);
const params = useParams();
const location = useLocation();
const { state: locationState, hash } = location;
const parentPage = typeof locationState?.from === "string" ? locationState.from : undefined;
const handleShareImageOpen = useCallback(() => setShareImageDialogOpen(true), []);
// Detect share mode from the route parameter.
const shareToken = params.token;
const isShareMode = !!shareToken;
// Primary memo fetch — share token or direct name.
const memoNameFromParams = params.uid ? `${memoNamePrefix}${params.uid}` : "";
const {
data: memoFromDirect,
error: directError,
isLoading: directLoading,
} = useMemo(memoNameFromParams, { enabled: !isShareMode && !!memoNameFromParams });
const { data: memoFromShare, error: shareError, isLoading: shareLoading } = useSharedMemo(shareToken ?? "", { enabled: isShareMode });
const memo = isShareMode ? memoFromShare : memoFromDirect;
const error = isShareMode ? shareError : directError;
const isLoading = isShareMode ? shareLoading : directLoading;
const memoName = memo?.name ?? memoNameFromParams;
const displayMemo = useReactMemo(() => {
if (!memo) return undefined;
if (!isShareMode) return memo;
return { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) };
}, [isShareMode, memo, shareToken]);
useMemoDetailError({
error: error as Error | null,
});
const { data: parentMemo } = useMemo(memo?.parent || "", {
enabled: !isShareMode && !!memo?.parent,
});
const {
data: comments = [],
fetchNextPage: fetchNextComments,
hasNextPage: hasNextComments,
isFetchingNextPage: isFetchingNextComments,
} = useInfiniteMemoComments(memoName, {
enabled: !isShareMode && !!memo,
});
// Scroll to the hash target once it's in the DOM. The effect re-runs as the memo loads (footnote
// anchors) and as comments arrive (comment anchors), since the target may render in either; the
// ref guards against re-scrolling the same hash on every later comments page-load.
const scrolledHashRef = useRef("");
useEffect(() => {
if (!hash || scrolledHashRef.current === hash) return;
const el = document.getElementById(decodeURIComponent(hash.slice(1)));
if (!el) return;
scrolledHashRef.current = hash;
el.scrollIntoView({ behavior: "smooth", block: "center" });
}, [hash, memo, comments]);
if (isShareMode) {
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);
if (isNotFound || (!isLoading && !memo)) {
return <Navigate to="/404" replace />;
}
}
// Start the permitted requests as soon as routing is unlocked, but do not
// expose content before tag-blur and instance display settings settle.
if (isLoading || !memo || !displayMemo || !authInitialized || !instanceInitialized) {
return null;
}
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 flex min-h-full w-full flex-col items-center pb-8 pt-3 md:pt-6">
<MentionResolutionProvider contents={mentionResolutionContents} userNames={userResolutionNames}>
<MemoSidebarRegistration memo={displayMemo} from={parentPage} readonly={isShareMode} onShareImageOpen={handleShareImageOpen} />
<div className="w-full max-w-2xl px-4 sm:px-6">
<div className="w-full">
{!isShareMode && parentMemo && (
<div className="w-auto inline-block mb-2">
<Link
className="px-3 py-1 border border-border rounded-lg max-w-xs w-auto text-sm flex flex-row justify-start items-center flex-nowrap text-muted-foreground hover:shadow hover:opacity-80"
to={`/${parentMemo.name}`}
state={locationState}
viewTransition
>
<ArrowUpLeftFromCircleIcon className="w-4 h-auto shrink-0 opacity-60 mr-2" />
<span className="truncate">{parentMemo.content}</span>
</Link>
</div>
)}
<MemoView
key={displayMemo.name}
memo={displayMemo}
compact={false}
parentPage={parentPage}
shareImageDialogOpen={shareImageDialogOpen}
showCreator
showVisibility
showPinned
onShareImageDialogOpenChange={setShareImageDialogOpen}
/>
{!isShareMode && (
<MemoCommentSection
memo={displayMemo}
comments={comments}
parentPage={parentPage}
hasMoreComments={hasNextComments}
isFetchingMoreComments={isFetchingNextComments}
onLoadMoreComments={fetchNextComments}
/>
)}
</div>
</div>
</MentionResolutionProvider>
</section>
);
};
export default MemoDetail;