refactor: implement MemoView component with subcomponents and hooks

- Added MemoView component to display a single memo card with full functionality including creator info, memo content, attachments, reactions, and comments.
- Created MemoBody and MemoHeader subcomponents to separate concerns and improve maintainability.
- Introduced custom hooks for managing memo actions, keyboard shortcuts, NSFW content visibility, and image preview.
- Implemented reaction handling with new ReactionSelector and ReactionView components.
- Added TypeScript types for better type safety and clarity.
- Established constants for memo card styling and keyboard shortcuts.
- Removed legacy ReactionSelector and ReactionView components from the previous structure.
This commit is contained in:
Johnny 2025-11-29 23:21:35 +08:00
parent 50199fe998
commit 1ef11f7470
30 changed files with 1418 additions and 812 deletions

View File

@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import {
ArchiveIcon,
ArchiveRestoreIcon,
@ -14,16 +13,8 @@ import {
} from "lucide-react";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog";
import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore, memoStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { hasCompletedTasks, removeCompletedTasks } from "@/utils/markdown-manipulation";
import { Button } from "./ui/button";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@ -32,134 +23,52 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
} from "@/components/ui/dropdown-menu";
import { State } from "@/types/proto/api/v1/common";
import { useTranslate } from "@/utils/i18n";
import { hasCompletedTasks } from "@/utils/markdown-manipulation";
import { useMemoActionHandlers } from "./hooks";
import type { MemoActionMenuProps } from "./types";
interface Props {
memo: Memo;
readonly?: boolean;
className?: string;
onEdit?: () => void;
}
const checkHasCompletedTaskList = (memo: Memo) => {
return hasCompletedTasks(memo.content);
};
const MemoActionMenu = observer((props: Props) => {
/**
* MemoActionMenu component provides a dropdown menu with actions for a memo:
* - Pin/Unpin
* - Edit
* - Copy (link/content)
* - Remove completed tasks
* - Archive/Restore
* - Delete
*/
const MemoActionMenu = observer((props: MemoActionMenuProps) => {
const { memo, readonly } = props;
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = useState(false);
const hasCompletedTaskList = checkHasCompletedTaskList(memo);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
// Derived state
const hasCompletedTaskList = hasCompletedTasks(memo.content);
const isComment = Boolean(memo.parent);
const isArchived = memo.state === State.ARCHIVED;
const memoUpdatedCallback = () => {
// Refresh user stats.
userStore.setStatsStateId();
};
const handleTogglePinMemoBtnClick = async () => {
try {
if (memo.pinned) {
await memoStore.updateMemo(
{
name: memo.name,
pinned: false,
},
["pinned"],
);
} else {
await memoStore.updateMemo(
{
name: memo.name,
pinned: true,
},
["pinned"],
);
}
} catch {
// do nth
}
};
const handleEditMemoClick = () => {
if (props.onEdit) {
props.onEdit();
return;
}
};
const handleToggleMemoStatusClick = async () => {
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
try {
await memoStore.updateMemo(
{
name: memo.name,
state,
},
["state"],
);
toast.success(message);
} catch (error: any) {
toast.error(error.details);
console.error(error);
return;
}
if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
}
memoUpdatedCallback();
};
const handleCopyLink = () => {
let host = instanceStore.state.profile.instanceUrl;
if (host === "") {
host = window.location.origin;
}
copy(`${host}/${memo.name}`);
toast.success(t("message.succeed-copy-link"));
};
const handleCopyContent = () => {
copy(memo.content);
toast.success(t("message.succeed-copy-content"));
};
const handleDeleteMemoClick = () => {
setDeleteDialogOpen(true);
};
const confirmDeleteMemo = async () => {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
};
const handleRemoveCompletedTaskListItemsClick = () => {
setRemoveTasksDialogOpen(true);
};
const confirmRemoveCompletedTaskListItems = async () => {
const newContent = removeCompletedTasks(memo.content);
await memoStore.updateMemo(
{
name: memo.name,
content: newContent,
},
["content"],
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
};
// Action handlers
const {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
confirmRemoveCompletedTaskListItems,
} = useMemoActionHandlers({
memo,
onEdit: props.onEdit,
setDeleteDialogOpen,
setRemoveTasksDialogOpen,
});
return (
<DropdownMenu>
@ -169,6 +78,7 @@ const MemoActionMenu = observer((props: Props) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}>
{/* Edit actions (non-readonly, non-archived) */}
{!readonly && !isArchived && (
<>
{!isComment && (
@ -183,6 +93,8 @@ const MemoActionMenu = observer((props: Props) => {
</DropdownMenuItem>
</>
)}
{/* Copy submenu (non-archived) */}
{!isArchived && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
@ -201,20 +113,27 @@ const MemoActionMenu = observer((props: Props) => {
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Write actions (non-readonly) */}
{!readonly && (
<>
{/* Remove completed tasks (non-archived, non-comment, has completed tasks) */}
{!isArchived && !isComment && hasCompletedTaskList && (
<DropdownMenuItem onClick={handleRemoveCompletedTaskListItemsClick}>
<SquareCheckIcon className="w-4 h-auto" />
{t("memo.remove-completed-task-list-items")}
</DropdownMenuItem>
)}
{/* Archive/Restore (non-comment) */}
{!isComment && (
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
{isArchived ? t("common.restore") : t("common.archive")}
</DropdownMenuItem>
)}
{/* Delete */}
<DropdownMenuItem onClick={handleDeleteMemoClick}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
@ -222,6 +141,7 @@ const MemoActionMenu = observer((props: Props) => {
</>
)}
</DropdownMenuContent>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
@ -233,6 +153,7 @@ const MemoActionMenu = observer((props: Props) => {
onConfirm={confirmDeleteMemo}
confirmVariant="destructive"
/>
{/* Remove completed tasks confirmation */}
<ConfirmDialog
open={removeTasksDialogOpen}

View File

@ -0,0 +1,131 @@
import copy from "copy-to-clipboard";
import { useCallback } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import useNavigateTo from "@/hooks/useNavigateTo";
import { instanceStore, memoStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import type { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { removeCompletedTasks } from "@/utils/markdown-manipulation";
interface UseMemoActionHandlersOptions {
memo: Memo;
onEdit?: () => void;
setDeleteDialogOpen: (open: boolean) => void;
setRemoveTasksDialogOpen: (open: boolean) => void;
}
/**
* Hook for handling memo action menu operations
*/
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRemoveTasksDialogOpen }: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const memoUpdatedCallback = useCallback(() => {
userStore.setStatsStateId();
}, []);
const handleTogglePinMemoBtnClick = useCallback(async () => {
try {
await memoStore.updateMemo(
{
name: memo.name,
pinned: !memo.pinned,
},
["pinned"],
);
} catch {
// do nothing
}
}, [memo.name, memo.pinned]);
const handleEditMemoClick = useCallback(() => {
onEdit?.();
}, [onEdit]);
const handleToggleMemoStatusClick = useCallback(async () => {
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
try {
await memoStore.updateMemo(
{
name: memo.name,
state,
},
["state"],
);
toast.success(message);
} catch (error: unknown) {
const err = error as { details?: string };
toast.error(err.details || "An error occurred");
console.error(error);
return;
}
if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
}
memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]);
const handleCopyLink = useCallback(() => {
let host = instanceStore.state.profile.instanceUrl;
if (host === "") {
host = window.location.origin;
}
copy(`${host}/${memo.name}`);
toast.success(t("message.succeed-copy-link"));
}, [memo.name, t]);
const handleCopyContent = useCallback(() => {
copy(memo.content);
toast.success(t("message.succeed-copy-content"));
}, [memo.content, t]);
const handleDeleteMemoClick = useCallback(() => {
setDeleteDialogOpen(true);
}, [setDeleteDialogOpen]);
const confirmDeleteMemo = useCallback(async () => {
await memoStore.deleteMemo(memo.name);
toast.success(t("message.deleted-successfully"));
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
}, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]);
const handleRemoveCompletedTaskListItemsClick = useCallback(() => {
setRemoveTasksDialogOpen(true);
}, [setRemoveTasksDialogOpen]);
const confirmRemoveCompletedTaskListItems = useCallback(async () => {
const newContent = removeCompletedTasks(memo.content);
await memoStore.updateMemo(
{
name: memo.name,
content: newContent,
},
["content"],
);
toast.success(t("message.remove-completed-task-list-items-successfully"));
memoUpdatedCallback();
}, [memo.name, memo.content, t, memoUpdatedCallback]);
return {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleDeleteMemoClick,
confirmDeleteMemo,
handleRemoveCompletedTaskListItemsClick,
confirmRemoveCompletedTaskListItems,
};
};

View File

@ -0,0 +1,3 @@
export { useMemoActionHandlers } from "./hooks";
export { default, default as MemoActionMenu } from "./MemoActionMenu";
export type { MemoActionMenuProps, UseMemoActionHandlersReturn } from "./types";

View File

@ -0,0 +1,30 @@
import type { Memo } from "@/types/proto/api/v1/memo_service";
/**
* Props for MemoActionMenu component
*/
export interface MemoActionMenuProps {
/** The memo to display actions for */
memo: Memo;
/** Whether the current user can only view (not edit) */
readonly?: boolean;
/** Additional CSS classes */
className?: string;
/** Callback when edit action is triggered */
onEdit?: () => void;
}
/**
* Return type for useMemoActionHandlers hook
*/
export interface UseMemoActionHandlersReturn {
handleTogglePinMemoBtnClick: () => Promise<void>;
handleEditMemoClick: () => void;
handleToggleMemoStatusClick: () => Promise<void>;
handleCopyLink: () => void;
handleCopyContent: () => void;
handleDeleteMemoClick: () => void;
confirmDeleteMemo: () => Promise<void>;
handleRemoveCompletedTaskListItemsClick: () => void;
confirmRemoveCompletedTaskListItems: () => Promise<void>;
}

View File

@ -0,0 +1,6 @@
export const MAX_DISPLAY_HEIGHT = 256;
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
SNIPPET: { textKey: "memo.show-less", next: "ALL" },
};

View File

@ -0,0 +1,27 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { COMPACT_STATES, MAX_DISPLAY_HEIGHT } from "./constants";
import type { ContentCompactView } from "./types";
export const useCompactMode = (enabled: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);
useEffect(() => {
if (!enabled || !containerRef.current) return;
if (containerRef.current.getBoundingClientRect().height > MAX_DISPLAY_HEIGHT) {
setMode("ALL");
}
}, [enabled]);
const toggle = useCallback(() => {
if (!mode) return;
setMode(COMPACT_STATES[mode].next);
}, [mode]);
return { containerRef, mode, toggle };
};
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
if (!mode) return "";
return t(COMPACT_STATES[mode].textKey);
};

View File

@ -1,5 +1,5 @@
import { observer } from "mobx-react-lite";
import { memo, useEffect, useRef, useState } from "react";
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkBreaks from "remark-breaks";
@ -13,34 +13,21 @@ import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { isSuperUser } from "@/utils/user";
import { CodeBlock } from "./CodeBlock";
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { useCompactLabel, useCompactMode } from "./hooks";
import { MemoContentContext } from "./MemoContentContext";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types";
// MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode.
const MAX_DISPLAY_HEIGHT = 256;
interface Props {
content: string;
memoName?: string;
compact?: boolean;
readonly?: boolean;
disableFilter?: boolean;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
parentPage?: string;
}
type ContentCompactView = "ALL" | "SNIPPET";
const MemoContent = observer((props: Props) => {
const MemoContent = observer((props: MemoContentProps) => {
const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
const t = useTranslate();
const currentUser = useCurrentUser();
const memoContentContainerRef = useRef<HTMLDivElement>(null);
const [showCompactMode, setShowCompactMode] = useState<ContentCompactView | undefined>(undefined);
const {
containerRef: memoContentContainerRef,
mode: showCompactMode,
toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact));
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
@ -53,37 +40,7 @@ const MemoContent = observer((props: Props) => {
containerRef: memoContentContainerRef,
};
// Initial compact mode.
useEffect(() => {
if (!props.compact) {
return;
}
if (!memoContentContainerRef.current) {
return;
}
if ((memoContentContainerRef.current as HTMLDivElement).getBoundingClientRect().height > MAX_DISPLAY_HEIGHT) {
setShowCompactMode("ALL");
}
}, []);
const onMemoContentClick = async (e: React.MouseEvent) => {
// Image clicks and other handlers
if (onClick) {
onClick(e);
}
};
const onMemoContentDoubleClick = async (e: React.MouseEvent) => {
if (onDoubleClick) {
onDoubleClick(e);
}
};
const compactStates = {
ALL: { text: t("memo.show-more"), nextState: "SNIPPET" },
SNIPPET: { text: t("memo.show-less"), nextState: "ALL" },
};
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
return (
<MemoContentContext.Provider value={contextValue}>
@ -91,12 +48,12 @@ const MemoContent = observer((props: Props) => {
<div
ref={memoContentContainerRef}
className={cn(
"markdown-content relative w-full max-w-full break-words text-base leading-6",
showCompactMode == "ALL" && "line-clamp-6 max-h-60",
"markdown-content relative w-full max-w-full wrap-break-word text-base leading-6",
showCompactMode === "ALL" && "line-clamp-6 max-h-60",
contentClassName,
)}
onClick={onMemoContentClick}
onDoubleClick={onMemoContentDoubleClick}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks, remarkTag, remarkPreserveType]}
@ -116,19 +73,18 @@ const MemoContent = observer((props: Props) => {
{content}
</ReactMarkdown>
</div>
{showCompactMode == "ALL" && (
<div className="absolute bottom-0 left-0 w-full h-12 bg-gradient-to-b from-transparent to-background pointer-events-none"></div>
{showCompactMode === "ALL" && (
<div className="absolute bottom-0 left-0 w-full h-12 bg-linear-to-b from-transparent to-background pointer-events-none"></div>
)}
{showCompactMode != undefined && (
{showCompactMode !== undefined && (
<div className="w-full mt-1">
<span
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80"
onClick={() => {
setShowCompactMode(compactStates[showCompactMode].nextState as ContentCompactView);
}}
<button
type="button"
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80 text-left"
onClick={toggleCompactMode}
>
{compactStates[showCompactMode].text}
</span>
{compactLabel}
</button>
</div>
)}
</div>

View File

@ -0,0 +1,16 @@
import type React from "react";
export interface MemoContentProps {
content: string;
memoName?: string;
compact?: boolean;
readonly?: boolean;
disableFilter?: boolean;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
parentPage?: string;
}
export type ContentCompactView = "ALL" | "SNIPPET";

View File

@ -1,48 +0,0 @@
import { uniq } from "lodash-es";
import { observer } from "mobx-react-lite";
import { memo, useEffect, useState } from "react";
import useCurrentUser from "@/hooks/useCurrentUser";
import { userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { Memo, Reaction } from "@/types/proto/api/v1/memo_service";
import { User } from "@/types/proto/api/v1/user_service";
import ReactionSelector from "./ReactionSelector";
import ReactionView from "./ReactionView";
interface Props {
memo: Memo;
reactions: Reaction[];
}
const MemoReactionListView = observer((props: Props) => {
const { memo, reactions } = props;
const currentUser = useCurrentUser();
const [reactionGroup, setReactionGroup] = useState<Map<string, User[]>>(new Map());
const readonly = memo.state === State.ARCHIVED;
useEffect(() => {
(async () => {
const reactionGroup = new Map<string, User[]>();
for (const reaction of reactions) {
const user = await userStore.getOrFetchUserByName(reaction.creator);
const users = reactionGroup.get(reaction.reactionType) || [];
users.push(user);
reactionGroup.set(reaction.reactionType, uniq(users));
}
setReactionGroup(reactionGroup);
})();
}, [reactions]);
return (
reactions.length > 0 && (
<div className="w-full flex flex-row justify-start items-start flex-wrap gap-1 select-none">
{Array.from(reactionGroup).map(([reactionType, users]) => {
return <ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memo} reactionType={reactionType} users={users} />;
})}
{!readonly && currentUser && <ReactionSelector memo={memo} />}
</div>
)
);
});
export default memo(MemoReactionListView);

View File

@ -0,0 +1,35 @@
import { observer } from "mobx-react-lite";
import { memo } from "react";
import useCurrentUser from "@/hooks/useCurrentUser";
import { State } from "@/types/proto/api/v1/common";
import { ReactionSelector, ReactionView } from "../reactions";
import { useReactionGroups } from "./hooks";
import type { MemoReactionListViewProps } from "./types";
/**
* MemoReactionListView displays the reactions on a memo:
* - Groups reactions by type
* - Shows reaction emoji with count
* - Allows adding new reactions (if not readonly)
*/
const MemoReactionListView = observer((props: MemoReactionListViewProps) => {
const { memo: memoData, reactions } = props;
const currentUser = useCurrentUser();
const reactionGroup = useReactionGroups(reactions);
const readonly = memoData.state === State.ARCHIVED;
if (reactions.length === 0) {
return null;
}
return (
<div className="w-full flex flex-row justify-start items-start flex-wrap gap-1 select-none">
{Array.from(reactionGroup).map(([reactionType, users]) => (
<ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memoData} reactionType={reactionType} users={users} />
))}
{!readonly && currentUser && <ReactionSelector memo={memoData} />}
</div>
);
});
export default memo(MemoReactionListView);

View File

@ -0,0 +1,32 @@
import { uniq } from "lodash-es";
import { useEffect, useState } from "react";
import { userStore } from "@/store";
import type { Reaction } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
import type { ReactionGroup } from "./types";
/**
* Hook for grouping reactions by type and fetching user data
*/
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
const [reactionGroup, setReactionGroup] = useState<ReactionGroup>(new Map());
useEffect(() => {
const fetchReactionGroups = async () => {
const newReactionGroup = new Map<string, User[]>();
for (const reaction of reactions) {
const user = await userStore.getOrFetchUserByName(reaction.creator);
const users = newReactionGroup.get(reaction.reactionType) || [];
users.push(user);
newReactionGroup.set(reaction.reactionType, uniq(users));
}
setReactionGroup(newReactionGroup);
};
fetchReactionGroups();
}, [reactions]);
return reactionGroup;
};

View File

@ -0,0 +1,3 @@
export { useReactionGroups } from "./hooks";
export { default, default as MemoReactionListView } from "./MemoReactionListView";
export type { MemoReactionListViewProps, ReactionGroup } from "./types";

View File

@ -0,0 +1,17 @@
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
/**
* Props for MemoReactionListView component
*/
export interface MemoReactionListViewProps {
/** The memo that reactions belong to */
memo: Memo;
/** List of reactions to display */
reactions: Reaction[];
}
/**
* Grouped reactions with users who reacted
*/
export type ReactionGroup = Map<string, User[]>;

View File

@ -1,375 +0,0 @@
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { Link, useLocation } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { instanceStore, memoStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { Memo, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityToString } from "@/utils/memo";
import { isSuperUser } from "@/utils/user";
import MemoActionMenu from "./MemoActionMenu";
import MemoContent from "./MemoContent";
import MemoEditor from "./MemoEditor";
import MemoReactionistView from "./MemoReactionListView";
import { AttachmentList, LocationDisplay, RelationList } from "./memo-metadata";
import PreviewImageDialog from "./PreviewImageDialog";
import ReactionSelector from "./ReactionSelector";
import UserAvatar from "./UserAvatar";
import VisibilityIcon from "./VisibilityIcon";
interface Props {
memo: Memo;
compact?: boolean;
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
showNsfwContent?: boolean;
className?: string;
parentPage?: string;
}
const MemoView: React.FC<Props> = observer((props: Props) => {
const { memo, className } = props;
const t = useTranslate();
const location = useLocation();
const navigateTo = useNavigateTo();
const currentUser = useCurrentUser();
const user = useCurrentUser();
const [showEditor, setShowEditor] = useState<boolean>(false);
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent);
const [reactionSelectorOpen, setReactionSelectorOpen] = useState<boolean>(false);
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const [shortcutActive, setShortcutActive] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
const commentAmount = memo.relations.filter(
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name,
).length;
const relativeTimeFormat = Date.now() - memo.displayTime!.getTime() > 1000 * 60 * 60 * 24 ? "datetime" : "auto";
const isArchived = memo.state === State.ARCHIVED;
const readonly = memo.creator !== user?.name && !isSuperUser(user);
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
const parentPage = props.parentPage || location.pathname;
const nsfw =
instanceMemoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
// Initial related data: creator.
useAsyncEffect(async () => {
const user = await userStore.getOrFetchUserByName(memo.creator);
setCreator(user);
}, []);
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memo.name}`, {
state: {
from: parentPage,
},
});
}, [memo.name, parentPage]);
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.tagName === "IMG") {
// Check if the image is inside a link
const linkElement = targetEl.closest("a");
if (linkElement) {
// If image is inside a link, only navigate to the link (don't show preview)
return;
}
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) {
setPreviewImage({ open: true, urls: [imgUrl], index: 0 });
}
}
}, []);
const handleMemoContentDoubleClick = useCallback(async (e: React.MouseEvent) => {
if (readonly) {
return;
}
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
e.preventDefault();
setShowEditor(true);
}
}, []);
const onEditorConfirm = () => {
setShowEditor(false);
userStore.setStatsStateId();
};
const onPinIconClick = async () => {
if (memo.pinned) {
await memoStore.updateMemo(
{
name: memo.name,
pinned: false,
},
["pinned"],
);
}
};
const archiveMemo = useCallback(async () => {
if (isArchived) {
return;
}
try {
await memoStore.updateMemo(
{
name: memo.name,
state: State.ARCHIVED,
},
["state"],
);
toast.success(t("message.archived-successfully"));
userStore.setStatsStateId();
} catch (error: any) {
console.error(error);
toast.error(error?.details);
}
}, [isArchived, memo.name, t, memoStore, userStore]);
useEffect(() => {
if (!shortcutActive || readonly || showEditor || !cardRef.current) {
return;
}
const cardEl = cardRef.current;
const isTextInputElement = (element: HTMLElement | null) => {
if (!element) {
return false;
}
if (element.isContentEditable) {
return true;
}
if (element instanceof HTMLTextAreaElement) {
return true;
}
if (element instanceof HTMLInputElement) {
const textTypes = ["text", "search", "email", "password", "url", "tel", "number"];
return textTypes.includes(element.type || "text");
}
return false;
};
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (!cardEl.contains(target) || isTextInputElement(target)) {
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
const key = event.key.toLowerCase();
if (key === "e") {
event.preventDefault();
setShowEditor(true);
} else if (key === "a" && !isArchived) {
event.preventDefault();
archiveMemo();
}
};
cardEl.addEventListener("keydown", handleKeyDown);
return () => cardEl.removeEventListener("keydown", handleKeyDown);
}, [shortcutActive, readonly, showEditor, isArchived, archiveMemo]);
useEffect(() => {
if (showEditor || readonly) {
setShortcutActive(false);
}
}, [showEditor, readonly]);
const handleShortcutActivation = (active: boolean) => {
if (readonly) {
return;
}
setShortcutActive(active);
};
const displayTime = isArchived ? (
memo.displayTime?.toLocaleString(i18n.language)
) : (
<relative-time datetime={memo.displayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat}></relative-time>
);
return showEditor ? (
<MemoEditor
autoFocus
className="mb-2"
cacheKey={`inline-memo-editor-${memo.name}`}
memoName={memo.name}
onConfirm={onEditorConfirm}
onCancel={() => setShowEditor(false)}
/>
) : (
<div
className={cn(
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors",
className,
)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
onFocus={() => handleShortcutActivation(true)}
onBlur={() => handleShortcutActivation(false)}
>
<div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{props.showCreator && creator ? (
<div className="w-full flex flex-row justify-start items-center">
<Link
className="w-auto hover:opacity-80 rounded-md transition-colors"
to={`/u/${encodeURIComponent(creator.username)}`}
viewTransition
>
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
</Link>
<div className="w-full flex flex-col justify-center items-start">
<Link
className="block leading-tight hover:opacity-80 rounded-md transition-colors truncate text-muted-foreground"
to={`/u/${encodeURIComponent(creator.username)}`}
viewTransition
>
{creator.displayName || creator.username}
</Link>
<div
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors"
onClick={handleGotoMemoDetailPage}
>
{displayTime}
</div>
</div>
</div>
) : (
<div
className="w-full text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors"
onClick={handleGotoMemoDetailPage}
>
{displayTime}
</div>
)}
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
{currentUser && !isArchived && (
<ReactionSelector
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "!block", "hidden group-hover:block")}
memo={memo}
onOpenChange={setReactionSelectorOpen}
/>
)}
{!isInMemoDetailPage && (
<Link
className={cn(
"flex flex-row justify-start items-center rounded-md p-1 hover:opacity-80",
commentAmount === 0 && "invisible group-hover:visible",
)}
to={`/${memo.name}#comments`}
viewTransition
state={{
from: parentPage,
}}
>
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-muted-foreground" />
{commentAmount > 0 && <span className="text-xs text-muted-foreground">{commentAmount}</span>}
</Link>
)}
{props.showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip>
<TooltipTrigger>
<span className="flex justify-center items-center rounded-md hover:opacity-80">
<VisibilityIcon visibility={memo.visibility} />
</span>
</TooltipTrigger>
<TooltipContent>{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)}</TooltipContent>
</Tooltip>
)}
{props.showPinned && memo.pinned && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer">
<BookmarkIcon className="w-4 h-auto text-primary" onClick={onPinIconClick} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("common.unpin")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{nsfw && showNSFWContent && (
<span className="cursor-pointer">
<EyeOffIcon className="w-4 h-auto text-primary" onClick={() => setShowNSFWContent(false)} />
</span>
)}
<MemoActionMenu memo={memo} readonly={readonly} onEdit={() => setShowEditor(true)} />
</div>
</div>
<div
className={cn(
"w-full flex flex-col justify-start items-start gap-2",
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
)}
>
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name}
content={memo.content}
readonly={readonly}
onClick={handleMemoContentClick}
onDoubleClick={handleMemoContentDoubleClick}
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
parentPage={parentPage}
/>
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
<AttachmentList mode="view" attachments={memo.attachments} />
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
</div>
{nsfw && !showNSFWContent && (
<>
<div className="absolute inset-0 bg-transparent" />
<button
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-muted-foreground hover:text-foreground hover:bg-accent hover:border-accent border border-border rounded-lg bg-card transition-colors"
onClick={() => setShowNSFWContent(true)}
>
{t("memo.click-to-show-nsfw-content")}
</button>
</>
)}
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</div>
);
});
export default memo(MemoView);

View File

@ -0,0 +1,185 @@
import { observer } from "mobx-react-lite";
import { memo, useCallback, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import useCurrentUser from "@/hooks/useCurrentUser";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { instanceStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { isSuperUser } from "@/utils/user";
import MemoEditor from "../MemoEditor";
import PreviewImageDialog from "../PreviewImageDialog";
import { MemoBody, MemoHeader } from "./components";
import { MEMO_CARD_BASE_CLASSES, RELATIVE_TIME_THRESHOLD_MS } from "./constants";
import { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks";
import type { MemoViewProps } from "./types";
/**
* MemoView component displays a single memo card with full functionality including:
* - Creator information and display time
* - Memo content with markdown rendering
* - Attachments and location
* - Reactions and comments
* - Edit mode with inline editor
* - Keyboard shortcuts for quick actions
* - NSFW content blur protection
*/
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
const { memo: memoData, className } = props;
const location = useLocation();
const navigateTo = useNavigateTo();
const user = useCurrentUser();
const cardRef = useRef<HTMLDivElement>(null);
// State
const [showEditor, setShowEditor] = useState(false);
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
// Fetch creator data
const creator = useMemoCreator(memoData.creator);
// Custom hooks for state management
const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
const { archiveMemo, unpinMemo } = useMemoActions(memoData);
// Derived state
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
const commentAmount = memoData.relations.filter(
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memoData.name,
).length;
const relativeTimeFormat =
memoData.displayTime && Date.now() - memoData.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto";
const isArchived = memoData.state === State.ARCHIVED;
const readonly = memoData.creator !== user?.name && !isSuperUser(user);
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
const parentPage = props.parentPage || location.pathname;
// Keyboard shortcuts
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
enabled: true,
readonly,
showEditor,
isArchived,
onEdit: () => setShowEditor(true),
onArchive: archiveMemo,
});
// Handlers
const handleGotoMemoDetailPage = useCallback(() => {
navigateTo(`/${memoData.name}`, {
state: { from: parentPage },
});
}, [memoData.name, parentPage, navigateTo]);
const handleMemoContentClick = useCallback(
(e: React.MouseEvent) => {
const targetEl = e.target as HTMLElement;
if (targetEl.tagName === "IMG") {
// Check if the image is inside a link
const linkElement = targetEl.closest("a");
if (linkElement) {
// If image is inside a link, only navigate to the link (don't show preview)
return;
}
const imgUrl = targetEl.getAttribute("src");
if (imgUrl) {
openPreview(imgUrl);
}
}
},
[openPreview],
);
const handleMemoContentDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (readonly) return;
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
e.preventDefault();
setShowEditor(true);
}
},
[readonly, instanceMemoRelatedSetting.enableDoubleClickEdit],
);
const handleEditorConfirm = useCallback(() => {
setShowEditor(false);
userStore.setStatsStateId();
}, []);
const handleEditorCancel = useCallback(() => {
setShowEditor(false);
}, []);
// Render inline editor when editing
if (showEditor) {
return (
<MemoEditor
autoFocus
className="mb-2"
cacheKey={`inline-memo-editor-${memoData.name}`}
memoName={memoData.name}
onConfirm={handleEditorConfirm}
onCancel={handleEditorCancel}
/>
);
}
// Render memo card
return (
<article
className={cn(MEMO_CARD_BASE_CLASSES, className)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
onFocus={() => handleShortcutActivation(true)}
onBlur={() => handleShortcutActivation(false)}
>
<MemoHeader
memo={memoData}
creator={creator}
showCreator={props.showCreator}
showVisibility={props.showVisibility}
showPinned={props.showPinned}
isArchived={isArchived}
commentAmount={commentAmount}
isInMemoDetailPage={isInMemoDetailPage}
parentPage={parentPage}
readonly={readonly}
relativeTimeFormat={relativeTimeFormat}
onEdit={() => setShowEditor(true)}
onGotoDetail={handleGotoMemoDetailPage}
onUnpin={unpinMemo}
onToggleNsfwVisibility={toggleNsfwVisibility}
nsfw={nsfw}
showNSFWContent={showNSFWContent}
reactionSelectorOpen={reactionSelectorOpen}
onReactionSelectorOpenChange={setReactionSelectorOpen}
/>
<MemoBody
memo={memoData}
readonly={readonly}
compact={props.compact}
parentPage={parentPage}
nsfw={nsfw}
showNSFWContent={showNSFWContent}
onContentClick={handleMemoContentClick}
onContentDoubleClick={handleMemoContentDoubleClick}
onToggleNsfwVisibility={toggleNsfwVisibility}
/>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</article>
);
});
export default memo(MemoView);

View File

@ -0,0 +1,73 @@
import { cn } from "@/lib/utils";
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import MemoContent from "../../MemoContent";
import { MemoReactionListView } from "../../MemoReactionListView";
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
import type { MemoBodyProps } from "../types";
/**
* MemoBody component displays the main content of a memo including:
* - Memo content (markdown)
* - Location display
* - Attachments
* - Related memos
* - Reactions
* - NSFW content overlay
*/
const MemoBody: React.FC<MemoBodyProps> = ({
memo,
readonly,
compact,
parentPage,
nsfw,
showNSFWContent,
onContentClick,
onContentDoubleClick,
onToggleNsfwVisibility,
}) => {
const t = useTranslate();
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
return (
<>
<div
className={cn(
"w-full flex flex-col justify-start items-start gap-2",
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
)}
>
<MemoContent
key={`${memo.name}-${memo.updateTime}`}
memoName={memo.name}
content={memo.content}
readonly={readonly}
onClick={onContentClick}
onDoubleClick={onContentDoubleClick}
compact={memo.pinned ? false : compact} // Always show full content when pinned
parentPage={parentPage}
/>
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
<AttachmentList mode="view" attachments={memo.attachments} />
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
<MemoReactionListView memo={memo} reactions={memo.reactions} />
</div>
{/* NSFW content overlay */}
{nsfw && !showNSFWContent && (
<>
<div className="absolute inset-0 bg-transparent" />
<button
type="button"
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-muted-foreground hover:text-foreground hover:bg-accent hover:border-accent border border-border rounded-lg bg-card transition-colors"
onClick={onToggleNsfwVisibility}
>
{t("memo.click-to-show-nsfw-content")}
</button>
</>
)}
</>
);
};
export default MemoBody;

View File

@ -0,0 +1,188 @@
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { Visibility } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { convertVisibilityToString } from "@/utils/memo";
import MemoActionMenu from "../../MemoActionMenu";
import { ReactionSelector } from "../../reactions";
import UserAvatar from "../../UserAvatar";
import VisibilityIcon from "../../VisibilityIcon";
import type { MemoHeaderProps } from "../types";
/**
* MemoHeader component displays the top section of a memo card including:
* - Creator info (avatar, name) when showCreator is true
* - Display time (relative or absolute)
* - Reaction selector
* - Comment count link
* - Visibility icon
* - Pin indicator
* - NSFW hide button
* - Action menu
*/
const MemoHeader: React.FC<MemoHeaderProps> = ({
memo,
creator,
showCreator,
showVisibility,
showPinned,
isArchived,
commentAmount,
isInMemoDetailPage,
parentPage,
readonly,
relativeTimeFormat,
onEdit,
onGotoDetail,
onUnpin,
onToggleNsfwVisibility,
nsfw,
showNSFWContent,
reactionSelectorOpen,
onReactionSelectorOpenChange,
}) => {
const t = useTranslate();
const displayTime = isArchived ? (
memo.displayTime?.toLocaleString(i18n.language)
) : (
<relative-time datetime={memo.displayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat}></relative-time>
);
return (
<div className="w-full flex flex-row justify-between items-center gap-2">
{/* Left section: Creator info or time */}
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator && creator ? (
<CreatorDisplay creator={creator} displayTime={displayTime} onGotoDetail={onGotoDetail} />
) : (
<TimeDisplay displayTime={displayTime} onGotoDetail={onGotoDetail} />
)}
</div>
{/* Right section: Actions */}
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
{/* Reaction selector */}
{!isArchived && (
<ReactionSelector
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "hidden group-hover:block")}
memo={memo}
onOpenChange={onReactionSelectorOpenChange}
/>
)}
{/* Comment count link */}
{!isInMemoDetailPage && (
<Link
className={cn(
"flex flex-row justify-start items-center rounded-md p-1 hover:opacity-80",
commentAmount === 0 && "invisible group-hover:visible",
)}
to={`/${memo.name}#comments`}
viewTransition
state={{ from: parentPage }}
>
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-muted-foreground" />
{commentAmount > 0 && <span className="text-xs text-muted-foreground">{commentAmount}</span>}
</Link>
)}
{/* Visibility icon */}
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip>
<TooltipTrigger>
<span className="flex justify-center items-center rounded-md hover:opacity-80">
<VisibilityIcon visibility={memo.visibility} />
</span>
</TooltipTrigger>
<TooltipContent>
{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as Parameters<typeof t>[0])}
</TooltipContent>
</Tooltip>
)}
{/* Pinned indicator */}
{showPinned && memo.pinned && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-pointer">
<BookmarkIcon className="w-4 h-auto text-primary" onClick={onUnpin} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{t("common.unpin")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* NSFW hide button */}
{nsfw && showNSFWContent && onToggleNsfwVisibility && (
<span className="cursor-pointer">
<EyeOffIcon className="w-4 h-auto text-primary" onClick={onToggleNsfwVisibility} />
</span>
)}
{/* Action menu */}
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} />
</div>
</div>
);
};
/**
* Creator display with avatar and name
*/
interface CreatorDisplayProps {
creator: NonNullable<MemoHeaderProps["creator"]>;
displayTime: React.ReactNode;
onGotoDetail: () => void;
}
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, onGotoDetail }) => (
<div className="w-full flex flex-row justify-start items-center">
<Link className="w-auto hover:opacity-80 rounded-md transition-colors" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
</Link>
<div className="w-full flex flex-col justify-center items-start">
<Link
className="block leading-tight hover:opacity-80 rounded-md transition-colors truncate text-muted-foreground"
to={`/u/${encodeURIComponent(creator.username)}`}
viewTransition
>
{creator.displayName || creator.username}
</Link>
<button
type="button"
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
onClick={onGotoDetail}
>
{displayTime}
</button>
</div>
</div>
);
/**
* Simple time display without creator info
*/
interface TimeDisplayProps {
displayTime: React.ReactNode;
onGotoDetail: () => void;
}
const TimeDisplay: React.FC<TimeDisplayProps> = ({ displayTime, onGotoDetail }) => (
<button
type="button"
className="w-full text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
onClick={onGotoDetail}
>
{displayTime}
</button>
);
export default MemoHeader;

View File

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

View File

@ -0,0 +1,19 @@
/**
* Constants for MemoView component
*/
/** CSS class for memo card styling */
export const MEMO_CARD_BASE_CLASSES =
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors";
/** Keyboard shortcut keys */
export const KEYBOARD_SHORTCUTS = {
EDIT: "e",
ARCHIVE: "a",
} as const;
/** Text input element types for keyboard shortcut filtering */
export const TEXT_INPUT_TYPES = ["text", "search", "email", "password", "url", "tel", "number"] as const;
/** Time threshold for relative time format (24 hours in milliseconds) */
export const RELATIVE_TIME_THRESHOLD_MS = 1000 * 60 * 60 * 24;

View File

@ -0,0 +1 @@
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState";

View File

@ -0,0 +1,205 @@
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { instanceStore, memoStore, userStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import type { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n";
import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
import type {
ImagePreviewState,
UseImagePreviewReturn,
UseKeyboardShortcutsOptions,
UseMemoActionsReturn,
UseNsfwContentReturn,
} from "../types";
/**
* Hook for handling memo actions (archive, unpin)
*/
export const useMemoActions = (memo: Memo): UseMemoActionsReturn => {
const t = useTranslate();
const isArchived = memo.state === State.ARCHIVED;
const archiveMemo = useCallback(async () => {
if (isArchived) {
return;
}
try {
await memoStore.updateMemo(
{
name: memo.name,
state: State.ARCHIVED,
},
["state"],
);
toast.success(t("message.archived-successfully"));
userStore.setStatsStateId();
} catch (error: unknown) {
console.error(error);
const err = error as { details?: string };
toast.error(err?.details || "Failed to archive memo");
}
}, [isArchived, memo.name, t]);
const unpinMemo = useCallback(async () => {
if (!memo.pinned) {
return;
}
await memoStore.updateMemo(
{
name: memo.name,
pinned: false,
},
["pinned"],
);
}, [memo.name, memo.pinned]);
return { archiveMemo, unpinMemo };
};
/**
* Hook for handling keyboard shortcuts on the memo card
*/
export const useKeyboardShortcuts = (
cardRef: React.RefObject<HTMLDivElement | null>,
options: UseKeyboardShortcutsOptions,
): {
shortcutActive: boolean;
handleShortcutActivation: (active: boolean) => void;
} => {
const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
const [shortcutActive, setShortcutActive] = useState(false);
const isTextInputElement = useCallback((element: HTMLElement | null): boolean => {
if (!element) return false;
if (element.isContentEditable) return true;
if (element instanceof HTMLTextAreaElement) return true;
if (element instanceof HTMLInputElement) {
return TEXT_INPUT_TYPES.includes(element.type as (typeof TEXT_INPUT_TYPES)[number]);
}
return false;
}, []);
useEffect(() => {
if (!enabled || readonly || showEditor || !cardRef.current) {
return;
}
const cardEl = cardRef.current;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (!cardEl.contains(target) || isTextInputElement(target)) {
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
const key = event.key.toLowerCase();
if (key === KEYBOARD_SHORTCUTS.EDIT) {
event.preventDefault();
onEdit();
} else if (key === KEYBOARD_SHORTCUTS.ARCHIVE && !isArchived) {
event.preventDefault();
onArchive();
}
};
cardEl.addEventListener("keydown", handleKeyDown);
return () => cardEl.removeEventListener("keydown", handleKeyDown);
}, [enabled, readonly, showEditor, isArchived, onEdit, onArchive, cardRef, isTextInputElement]);
useEffect(() => {
if (showEditor || readonly) {
setShortcutActive(false);
}
}, [showEditor, readonly]);
const handleShortcutActivation = useCallback(
(active: boolean) => {
if (readonly) return;
setShortcutActive(active);
},
[readonly],
);
return { shortcutActive, handleShortcutActivation };
};
/**
* Hook for managing NSFW content visibility
*/
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
const nsfw =
instanceMemoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
const toggleNsfwVisibility = useCallback(() => {
setShowNSFWContent((prev) => !prev);
}, []);
return {
nsfw: nsfw ?? false,
showNSFWContent,
toggleNsfwVisibility,
};
};
/**
* Hook for managing image preview dialog state
*/
export const useImagePreview = (): UseImagePreviewReturn => {
const [previewState, setPreviewState] = useState<ImagePreviewState>({
open: false,
urls: [],
index: 0,
});
const openPreview = useCallback((url: string) => {
setPreviewState({ open: true, urls: [url], index: 0 });
}, []);
const closePreview = useCallback(() => {
setPreviewState((prev) => ({ ...prev, open: false }));
}, []);
const setPreviewOpen = useCallback((open: boolean) => {
setPreviewState((prev) => ({ ...prev, open }));
}, []);
return {
previewState,
openPreview,
closePreview,
setPreviewOpen,
};
};
/**
* Hook for fetching and managing memo creator data
*/
export const useMemoCreator = (creatorName: string) => {
const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
(async () => {
const user = await userStore.getOrFetchUserByName(creatorName);
setCreator(user);
})();
}, [creatorName]);
return creator;
};

View File

@ -0,0 +1,24 @@
/**
* MemoView component and related exports
*
* This module provides a fully refactored MemoView component with:
* - Separation of concerns via custom hooks
* - Smaller, focused sub-components
* - Proper TypeScript types
* - Better maintainability and testability
*/
export { MemoBody, MemoHeader } from "./components";
export * from "./constants";
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks";
export { default, default as MemoView } from "./MemoView";
export type {
ImagePreviewState,
MemoBodyProps,
MemoHeaderProps,
MemoViewProps,
UseImagePreviewReturn,
UseKeyboardShortcutsOptions,
UseMemoActionsReturn,
UseNsfwContentReturn,
} from "./types";

View File

@ -0,0 +1,112 @@
import type { Memo } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
/**
* Props for the MemoView component
*/
export interface MemoViewProps {
/** The memo data to display */
memo: Memo;
/** Enable compact mode with truncated content */
compact?: boolean;
/** Show creator avatar and name */
showCreator?: boolean;
/** Show visibility icon */
showVisibility?: boolean;
/** Show pinned indicator */
showPinned?: boolean;
/** Show NSFW content without blur */
showNsfwContent?: boolean;
/** Additional CSS classes */
className?: string;
/** Parent page path for navigation state */
parentPage?: string;
}
/**
* Props for the MemoHeader component
*/
export interface MemoHeaderProps {
memo: Memo;
creator: User | undefined;
showCreator?: boolean;
showVisibility?: boolean;
showPinned?: boolean;
isArchived: boolean;
commentAmount: number;
isInMemoDetailPage: boolean;
parentPage: string;
readonly: boolean;
relativeTimeFormat: "datetime" | "auto";
onEdit: () => void;
onGotoDetail: () => void;
onUnpin: () => void;
onToggleNsfwVisibility?: () => void;
nsfw?: boolean;
showNSFWContent?: boolean;
reactionSelectorOpen: boolean;
onReactionSelectorOpenChange: (open: boolean) => void;
}
/**
* Props for the MemoBody component
*/
export interface MemoBodyProps {
memo: Memo;
readonly: boolean;
compact?: boolean;
parentPage: string;
nsfw: boolean;
showNSFWContent: boolean;
onContentClick: (e: React.MouseEvent) => void;
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
}
/**
* State for image preview dialog
*/
export interface ImagePreviewState {
open: boolean;
urls: string[];
index: number;
}
/**
* Return type for useMemoActions hook
*/
export interface UseMemoActionsReturn {
archiveMemo: () => Promise<void>;
unpinMemo: () => Promise<void>;
}
/**
* Return type for useKeyboardShortcuts hook
*/
export interface UseKeyboardShortcutsOptions {
enabled: boolean;
readonly: boolean;
showEditor: boolean;
isArchived: boolean;
onEdit: () => void;
onArchive: () => Promise<void>;
}
/**
* Return type for useNsfwContent hook
*/
export interface UseNsfwContentReturn {
nsfw: boolean;
showNSFWContent: boolean;
toggleNsfwVisibility: () => void;
}
/**
* Return type for useImagePreview hook
*/
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (url: string) => void;
closePreview: () => void;
setPreviewOpen: (open: boolean) => void;
}

View File

@ -1,100 +0,0 @@
import { SmilePlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useRef, useState } from "react";
import useClickAway from "react-use/lib/useClickAway";
import { memoServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { instanceStore, memoStore } from "@/store";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface Props {
memo: Memo;
className?: string;
onOpenChange?: (open: boolean) => void;
}
const ReactionSelector = observer((props: Props) => {
const { memo, className, onOpenChange } = props;
const currentUser = useCurrentUser();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
useClickAway(containerRef, () => {
setOpen(false);
onOpenChange?.(false);
});
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
};
const hasReacted = (reactionType: string) => {
return memo.reactions.some((r) => r.reactionType === reactionType && r.creator === currentUser?.name);
};
const handleReactionClick = async (reactionType: string) => {
try {
if (hasReacted(reactionType)) {
const reactions = memo.reactions.filter(
(reaction) => reaction.reactionType === reactionType && reaction.creator === currentUser.name,
);
for (const reaction of reactions) {
await memoServiceClient.deleteMemoReaction({ name: reaction.name });
}
} else {
await memoServiceClient.upsertMemoReaction({
name: memo.name,
reaction: {
contentId: memo.name,
reactionType: reactionType,
},
});
}
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
} catch {
// skip error.
}
handleOpenChange(false);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<span
className={cn(
"h-7 w-7 flex justify-center items-center rounded-full border cursor-pointer transition-all hover:opacity-80",
className,
)}
>
<SmilePlusIcon className="w-4 h-4 mx-auto text-muted-foreground" />
</span>
</PopoverTrigger>
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
<div ref={containerRef}>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">
{instanceMemoRelatedSetting.reactions.map((reactionType) => {
return (
<span
key={reactionType}
className={cn(
"inline-flex w-auto text-base cursor-pointer rounded px-1 text-muted-foreground hover:opacity-80 transition-colors",
hasReacted(reactionType) && "bg-secondary text-secondary-foreground",
)}
onClick={() => handleReactionClick(reactionType)}
>
{reactionType}
</span>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
);
});
export default ReactionSelector;

View File

@ -1,92 +0,0 @@
import { observer } from "mobx-react-lite";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { memoServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
import { State } from "@/types/proto/api/v1/common";
import { Memo } from "@/types/proto/api/v1/memo_service";
import { User } from "@/types/proto/api/v1/user_service";
interface Props {
memo: Memo;
reactionType: string;
users: User[];
}
const stringifyUsers = (users: User[], reactionType: string): string => {
if (users.length === 0) {
return "";
}
if (users.length < 5) {
return users.map((user) => user.displayName || user.username).join(", ") + " reacted with " + reactionType.toLowerCase();
}
return (
`${users
.slice(0, 4)
.map((user) => user.displayName || user.username)
.join(", ")} and ${users.length - 4} more reacted with ` + reactionType.toLowerCase()
);
};
const ReactionView = observer((props: Props) => {
const { memo, reactionType, users } = props;
const currentUser = useCurrentUser();
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
const readonly = memo.state === State.ARCHIVED;
const handleReactionClick = async () => {
if (!currentUser || readonly) {
return;
}
const index = users.findIndex((user) => user.username === currentUser.username);
try {
if (index === -1) {
await memoServiceClient.upsertMemoReaction({
name: memo.name,
reaction: {
contentId: memo.name,
reactionType,
},
});
} else {
const reactions = memo.reactions.filter(
(reaction) => reaction.reactionType === reactionType && reaction.creator === currentUser.name,
);
for (const reaction of reactions) {
await memoServiceClient.deleteMemoReaction({ name: reaction.name });
}
}
} catch {
// Skip error.
}
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"h-7 border px-2 py-0.5 rounded-full flex flex-row justify-center items-center gap-1",
"text-sm text-muted-foreground",
currentUser && !readonly && "cursor-pointer",
hasReaction && "bg-accent border-border",
)}
onClick={handleReactionClick}
>
<span>{reactionType}</span>
<span className="opacity-60">{users.length}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{stringifyUsers(users, reactionType)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
export default ReactionView;

View File

@ -0,0 +1,64 @@
import { SmilePlusIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useCallback, useState } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { instanceStore } from "@/store";
import { useReactionActions } from "./hooks";
import type { ReactionSelectorProps } from "./types";
/**
* ReactionSelector component provides a popover for selecting emoji reactions
*/
const ReactionSelector = observer((props: ReactionSelectorProps) => {
const { memo, className, onOpenChange } = props;
const [open, setOpen] = useState(false);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
},
[onOpenChange],
);
const { hasReacted, handleReactionClick } = useReactionActions({
memo,
onComplete: () => handleOpenChange(false),
});
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<span
className={cn(
"h-7 w-7 flex justify-center items-center rounded-full border cursor-pointer transition-all hover:opacity-80",
className,
)}
>
<SmilePlusIcon className="w-4 h-4 mx-auto text-muted-foreground" />
</span>
</PopoverTrigger>
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">
{instanceMemoRelatedSetting.reactions.map((reactionType) => (
<button
type="button"
key={reactionType}
className={cn(
"inline-flex w-auto text-base cursor-pointer rounded px-1 text-muted-foreground hover:opacity-80 transition-colors",
hasReacted(reactionType) && "bg-secondary text-secondary-foreground",
)}
onClick={() => handleReactionClick(reactionType)}
>
{reactionType}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
});
export default ReactionSelector;

View File

@ -0,0 +1,56 @@
import { observer } from "mobx-react-lite";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common";
import { formatReactionTooltip, useReactionActions } from "./hooks";
import type { ReactionViewProps } from "./types";
/**
* ReactionView component displays a single reaction pill with count
* Clicking toggles the reaction for the current user
*/
const ReactionView = observer((props: ReactionViewProps) => {
const { memo, reactionType, users } = props;
const currentUser = useCurrentUser();
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
const readonly = memo.state === State.ARCHIVED;
const { handleReactionClick } = useReactionActions({ memo });
const handleClick = () => {
if (!currentUser || readonly) return;
handleReactionClick(reactionType);
};
const isClickable = currentUser && !readonly;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"h-7 border px-2 py-0.5 rounded-full flex flex-row justify-center items-center gap-1",
"text-sm text-muted-foreground",
isClickable && "cursor-pointer",
!isClickable && "cursor-default",
hasReaction && "bg-accent border-border",
)}
onClick={handleClick}
disabled={!isClickable}
>
<span>{reactionType}</span>
<span className="opacity-60">{users.length}</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{formatReactionTooltip(users, reactionType)}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
export default ReactionView;

View File

@ -0,0 +1,77 @@
import { useCallback } from "react";
import { memoServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoStore } from "@/store";
import type { Memo } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
interface UseReactionActionsOptions {
memo: Memo;
onComplete?: () => void;
}
/**
* Hook for handling reaction add/remove operations
*/
export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptions) => {
const currentUser = useCurrentUser();
const hasReacted = useCallback(
(reactionType: string) => {
return memo.reactions.some((r) => r.reactionType === reactionType && r.creator === currentUser?.name);
},
[memo.reactions, currentUser?.name],
);
const handleReactionClick = useCallback(
async (reactionType: string) => {
if (!currentUser) return;
try {
if (hasReacted(reactionType)) {
const reactions = memo.reactions.filter(
(reaction) => reaction.reactionType === reactionType && reaction.creator === currentUser.name,
);
for (const reaction of reactions) {
await memoServiceClient.deleteMemoReaction({ name: reaction.name });
}
} else {
await memoServiceClient.upsertMemoReaction({
name: memo.name,
reaction: {
contentId: memo.name,
reactionType,
},
});
}
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
} catch {
// skip error
}
onComplete?.();
},
[memo, currentUser, hasReacted, onComplete],
);
return {
hasReacted,
handleReactionClick,
};
};
/**
* Format users list for tooltip display
*/
export const formatReactionTooltip = (users: User[], reactionType: string): string => {
if (users.length === 0) {
return "";
}
const formatUserName = (user: User) => user.displayName || user.username;
if (users.length < 5) {
return `${users.map(formatUserName).join(", ")} reacted with ${reactionType.toLowerCase()}`;
}
return `${users.slice(0, 4).map(formatUserName).join(", ")} and ${users.length - 4} more reacted with ${reactionType.toLowerCase()}`;
};

View File

@ -0,0 +1,12 @@
/**
* Reaction components for memos
*
* This module provides components for displaying and managing reactions on memos:
* - ReactionSelector: Popover for selecting emoji reactions
* - ReactionView: Display a single reaction with count and tooltip
*/
export { formatReactionTooltip, useReactionActions } from "./hooks";
export { default as ReactionSelector } from "./ReactionSelector";
export { default as ReactionView } from "./ReactionView";
export type { ReactionSelectorProps, ReactionViewProps } from "./types";

View File

@ -0,0 +1,26 @@
import type { Memo } from "@/types/proto/api/v1/memo_service";
import type { User } from "@/types/proto/api/v1/user_service";
/**
* Props for ReactionSelector component
*/
export interface ReactionSelectorProps {
/** The memo to add reactions to */
memo: Memo;
/** Additional CSS classes */
className?: string;
/** Callback when popover open state changes */
onOpenChange?: (open: boolean) => void;
}
/**
* Props for ReactionView component
*/
export interface ReactionViewProps {
/** The memo that the reaction belongs to */
memo: Memo;
/** The emoji/reaction type */
reactionType: string;
/** Users who added this reaction */
users: User[];
}