feat: show inline comment preview in list view

Add a comment preview section below memo cards in list view, displaying
up to 3 comment snippets with a "View all" link. Removes the old comment
count icon from the memo header in favor of this richer inline display.
Comment preview is hidden in memo detail view.
This commit is contained in:
Steven 2026-03-03 21:40:56 +08:00
parent 3e4c052f44
commit 3a5d3c8ff9
4 changed files with 93 additions and 40 deletions

View File

@ -1,12 +1,14 @@
import { memo, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUser } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { isSuperUser } from "@/utils/user";
import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoHeader } from "./components";
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES } from "./constants";
import { useImagePreview, useMemoActions, useMemoHandlers } from "./hooks";
import { MemoViewContext } from "./MemoViewContext";
@ -42,6 +44,13 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
openPreview,
});
const location = useLocation();
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
const commentAmount = memoData.relations.filter(
(r) => r.type === MemoRelation_Type.COMMENT && r.relatedMemo?.name === memoData.name,
).length;
const showCommentPreview = !isInMemoDetailPage && commentAmount > 0;
const contextValue = useMemo(
() => ({
memo: memoData,
@ -69,32 +78,47 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
);
}
const article = (
<article
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
<MemoHeader
showCreator={props.showCreator}
showVisibility={props.showVisibility}
showPinned={props.showPinned}
onEdit={openEditor}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
/>
<MemoBody
compact={props.compact}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</article>
);
return (
<MemoViewContext.Provider value={contextValue}>
<article className={cn(MEMO_CARD_BASE_CLASSES, className)} ref={cardRef} tabIndex={readonly ? -1 : 0}>
<MemoHeader
showCreator={props.showCreator}
showVisibility={props.showVisibility}
showPinned={props.showPinned}
onEdit={openEditor}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
/>
<MemoBody
compact={props.compact}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</article>
{showCommentPreview ? (
<div className="mb-2">
{article}
<MemoCommentListView />
</div>
) : (
article
)}
</MemoViewContext.Provider>
);
};

View File

@ -0,0 +1,40 @@
import { ArrowUpRightIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { useMemoComments } from "@/hooks/useMemoQueries";
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
const MemoCommentListView: React.FC = () => {
const { memo } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0 });
const comments = data?.memos ?? [];
if (isInMemoDetailPage || commentAmount === 0) {
return null;
}
const displayedComments = comments.slice(0, 3);
return (
<div className="border border-t-0 border-border rounded-b-xl 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
to={`/${memo.name}#comments`}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground hover:underline underline-offset-2 transition-colors"
>
View all
<ArrowUpRightIcon className="w-3 h-3" />
</Link>
</div>
{displayedComments.map((comment) => (
<div key={comment.name} className="bg-muted/60 rounded-md px-2 py-1 text-xs text-muted-foreground truncate leading-relaxed">
{comment.content}
</div>
))}
</div>
);
};
export default MemoCommentListView;

View File

@ -1,5 +1,5 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon, MessageCircleMoreIcon } from "lucide-react";
import { BookmarkIcon } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
@ -20,8 +20,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
const { memo, creator, currentUser, parentPage, isArchived, readonly } = useMemoViewContext();
const { isInMemoDetailPage, commentAmount, relativeTimeFormat } = useMemoViewDerived();
const { memo, creator, currentUser, isArchived, readonly } = useMemoViewContext();
const { relativeTimeFormat } = useMemoViewDerived();
const displayTime = isArchived ? (
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
@ -52,18 +52,6 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
/>
)}
{!isInMemoDetailPage && commentAmount > 0 && (
<Link
className={cn("flex flex-row justify-start items-center rounded-md px-1 hover:opacity-80 gap-0.5")}
to={`/${memo.name}#comments`}
viewTransition
state={{ from: parentPage }}
>
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-muted-foreground" />
<span className="text-xs text-muted-foreground">{commentAmount}</span>
</Link>
)}
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip>
<TooltipTrigger>

View File

@ -1,2 +1,3 @@
export { default as MemoBody } from "./MemoBody";
export { default as MemoCommentListView } from "./MemoCommentListView";
export { default as MemoHeader } from "./MemoHeader";