diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx
index 549a0e215..8e72dc34a 100644
--- a/web/src/components/MemoView/MemoView.tsx
+++ b/web/src/components/MemoView/MemoView.tsx
@@ -29,6 +29,27 @@ interface Props {
parentPage?: string;
}
+/**
+ * MemoView component displays a memo card with all its content, metadata, and interactive elements.
+ *
+ * Features:
+ * - Displays memo content with markdown rendering
+ * - Shows creator information and timestamps
+ * - Supports inline editing with keyboard shortcuts (e = edit, a = archive)
+ * - Handles NSFW content blurring
+ * - Image preview on click
+ * - Comments, reactions, and relations
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
const MemoView: React.FC = observer((props: Props) => {
const { memo: memoData, className } = props;
const cardRef = useRef(null);
@@ -59,7 +80,8 @@ const MemoView: React.FC = observer((props: Props) => {
onArchive: archiveMemo,
});
- const contextValue = useMemo(
+ // Memoize static values that rarely change
+ const staticContextValue = useMemo(
() => ({
memo: memoData,
creator,
@@ -67,12 +89,28 @@ const MemoView: React.FC = observer((props: Props) => {
readonly,
isInMemoDetailPage,
parentPage,
+ }),
+ [memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage],
+ );
+
+ // Memoize dynamic values separately
+ const dynamicContextValue = useMemo(
+ () => ({
commentAmount,
relativeTimeFormat,
nsfw,
showNSFWContent,
}),
- [memoData, creator, isArchived, readonly, isInMemoDetailPage, parentPage, commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
+ [commentAmount, relativeTimeFormat, nsfw, showNSFWContent],
+ );
+
+ // Combine context values
+ const contextValue = useMemo(
+ () => ({
+ ...staticContextValue,
+ ...dynamicContextValue,
+ }),
+ [staticContextValue, dynamicContextValue],
);
if (showEditor) {
diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx
index 25cd6f39f..b16f7a37f 100644
--- a/web/src/components/MemoView/MemoViewContext.tsx
+++ b/web/src/components/MemoView/MemoViewContext.tsx
@@ -2,19 +2,26 @@ import { createContext, useContext } from "react";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
-export interface MemoViewContextValue {
+// Stable values that rarely change
+export interface MemoViewStaticContextValue {
memo: Memo;
creator: User | undefined;
isArchived: boolean;
readonly: boolean;
isInMemoDetailPage: boolean;
parentPage: string;
+}
+
+// Dynamic values that change frequently
+export interface MemoViewDynamicContextValue {
commentAmount: number;
relativeTimeFormat: "datetime" | "auto";
nsfw: boolean;
showNSFWContent: boolean;
}
+export interface MemoViewContextValue extends MemoViewStaticContextValue, MemoViewDynamicContextValue {}
+
export const MemoViewContext = createContext(null);
export const useMemoViewContext = (): MemoViewContextValue => {
diff --git a/web/src/components/MemoView/hooks/index.ts b/web/src/components/MemoView/hooks/index.ts
index 89c1fa129..5408ce818 100644
--- a/web/src/components/MemoView/hooks/index.ts
+++ b/web/src/components/MemoView/hooks/index.ts
@@ -1,4 +1,8 @@
+export { useMemoActions } from "./useMemoActions";
+export { useMemoCreator } from "./useMemoCreator";
export { useMemoEditor } from "./useMemoEditor";
export { useMemoHandlers } from "./useMemoHandlers";
+export { useImagePreview } from "./useImagePreview";
+export { useKeyboardShortcuts } from "./useKeyboardShortcuts";
export { useMemoViewDerivedState } from "./useMemoViewDerivedState";
-export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState";
+export { useNsfwContent } from "./useNsfwContent";
diff --git a/web/src/components/MemoView/hooks/useImagePreview.ts b/web/src/components/MemoView/hooks/useImagePreview.ts
new file mode 100644
index 000000000..d5fb46207
--- /dev/null
+++ b/web/src/components/MemoView/hooks/useImagePreview.ts
@@ -0,0 +1,25 @@
+import { useState } from "react";
+
+export interface ImagePreviewState {
+ open: boolean;
+ urls: string[];
+ index: number;
+}
+
+export interface UseImagePreviewReturn {
+ previewState: ImagePreviewState;
+ openPreview: (url: string) => void;
+ closePreview: () => void;
+ setPreviewOpen: (open: boolean) => void;
+}
+
+export const useImagePreview = (): UseImagePreviewReturn => {
+ const [previewState, setPreviewState] = useState({ open: false, urls: [], index: 0 });
+
+ return {
+ previewState,
+ openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }),
+ closePreview: () => setPreviewState({ open: false, urls: [], index: 0 }),
+ setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
+ };
+};
diff --git a/web/src/components/MemoView/hooks/useKeyboardShortcuts.ts b/web/src/components/MemoView/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 000000000..07e4a02ab
--- /dev/null
+++ b/web/src/components/MemoView/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,48 @@
+import { useEffect } from "react";
+import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
+
+export interface UseKeyboardShortcutsOptions {
+ enabled: boolean;
+ readonly: boolean;
+ showEditor: boolean;
+ isArchived: boolean;
+ onEdit: () => void;
+ onArchive: () => Promise;
+}
+
+const isTextInputElement = (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;
+};
+
+export const useKeyboardShortcuts = (cardRef: React.RefObject, options: UseKeyboardShortcutsOptions) => {
+ const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
+
+ 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]);
+};
diff --git a/web/src/components/MemoView/hooks/useMemoActions.ts b/web/src/components/MemoView/hooks/useMemoActions.ts
new file mode 100644
index 000000000..5e41d92b0
--- /dev/null
+++ b/web/src/components/MemoView/hooks/useMemoActions.ts
@@ -0,0 +1,30 @@
+import toast from "react-hot-toast";
+import { memoStore, userStore } from "@/store";
+import { State } from "@/types/proto/api/v1/common_pb";
+import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+import { useTranslate } from "@/utils/i18n";
+
+export const useMemoActions = (memo: Memo) => {
+ const t = useTranslate();
+ const isArchived = memo.state === State.ARCHIVED;
+
+ const archiveMemo = 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");
+ }
+ };
+
+ const unpinMemo = async () => {
+ if (!memo.pinned) return;
+ await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
+ };
+
+ return { archiveMemo, unpinMemo };
+};
diff --git a/web/src/components/MemoView/hooks/useMemoCreator.ts b/web/src/components/MemoView/hooks/useMemoCreator.ts
new file mode 100644
index 000000000..0a02258d8
--- /dev/null
+++ b/web/src/components/MemoView/hooks/useMemoCreator.ts
@@ -0,0 +1,12 @@
+import { useEffect, useState } from "react";
+import { userStore } from "@/store";
+
+export const useMemoCreator = (creatorName: string) => {
+ const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
+
+ useEffect(() => {
+ userStore.getOrFetchUser(creatorName).then(setCreator);
+ }, [creatorName]);
+
+ return creator;
+};
diff --git a/web/src/components/MemoView/hooks/useMemoViewState.ts b/web/src/components/MemoView/hooks/useMemoViewState.ts
deleted file mode 100644
index f75309dfe..000000000
--- a/web/src/components/MemoView/hooks/useMemoViewState.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { 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_pb";
-import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
-import { useTranslate } from "@/utils/i18n";
-import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
-import type { ImagePreviewState, UseKeyboardShortcutsOptions } from "../types";
-
-export const useMemoActions = (memo: Memo) => {
- const t = useTranslate();
- const isArchived = memo.state === State.ARCHIVED;
-
- const archiveMemo = 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");
- }
- };
-
- const unpinMemo = async () => {
- if (!memo.pinned) return;
- await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
- };
-
- return { archiveMemo, unpinMemo };
-};
-
-const isTextInputElement = (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;
-};
-
-export const useKeyboardShortcuts = (cardRef: React.RefObject, options: UseKeyboardShortcutsOptions) => {
- const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
-
- 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]);
-};
-
-export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean) => {
- 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}/`)));
-
- return {
- nsfw: nsfw ?? false,
- showNSFWContent,
- toggleNsfwVisibility: () => setShowNSFWContent((prev) => !prev),
- };
-};
-
-export const useImagePreview = () => {
- const [previewState, setPreviewState] = useState({ open: false, urls: [], index: 0 });
-
- return {
- previewState,
- openPreview: (url: string) => setPreviewState({ open: true, urls: [url], index: 0 }),
- setPreviewOpen: (open: boolean) => setPreviewState((prev) => ({ ...prev, open })),
- };
-};
-
-export const useMemoCreator = (creatorName: string) => {
- const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
-
- useEffect(() => {
- userStore.getOrFetchUser(creatorName).then(setCreator);
- }, [creatorName]);
-
- return creator;
-};
diff --git a/web/src/components/MemoView/hooks/useNsfwContent.ts b/web/src/components/MemoView/hooks/useNsfwContent.ts
new file mode 100644
index 000000000..205964c08
--- /dev/null
+++ b/web/src/components/MemoView/hooks/useNsfwContent.ts
@@ -0,0 +1,24 @@
+import { useState } from "react";
+import { instanceStore } from "@/store";
+import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
+
+export interface UseNsfwContentReturn {
+ nsfw: boolean;
+ showNSFWContent: boolean;
+ toggleNsfwVisibility: () => void;
+}
+
+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}/`)));
+
+ return {
+ nsfw: nsfw ?? false,
+ showNSFWContent,
+ toggleNsfwVisibility: () => setShowNSFWContent((prev) => !prev),
+ };
+};
diff --git a/web/src/components/MemoView/index.ts b/web/src/components/MemoView/index.ts
index b9e832f8c..5c3f6e909 100644
--- a/web/src/components/MemoView/index.ts
+++ b/web/src/components/MemoView/index.ts
@@ -1,3 +1,2 @@
-export { MemoBody, MemoHeader } from "./components";
-export * from "./constants";
export { default, default as MemoView } from "./MemoView";
+export type { MemoViewProps } from "./types";
diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts
index 07d86dd72..613ab2590 100644
--- a/web/src/components/MemoView/types.ts
+++ b/web/src/components/MemoView/types.ts
@@ -1,17 +1,33 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
-import type { User } from "@/types/proto/api/v1/user_service_pb";
+/**
+ * Props for the MemoView component.
+ * MemoView is the main component for displaying a memo card with all its metadata,
+ * content, and interactive elements.
+ */
export interface MemoViewProps {
+ /** The memo object to display */
memo: Memo;
+ /** Whether to show compact view (hides some metadata) */
compact?: boolean;
+ /** Whether to show the creator's profile information */
showCreator?: boolean;
+ /** Whether to show the visibility indicator */
showVisibility?: boolean;
+ /** Whether to show the pinned indicator */
showPinned?: boolean;
+ /** Whether to show NSFW content by default */
showNsfwContent?: boolean;
+ /** Additional CSS classes to apply to the root element */
className?: string;
+ /** The parent page URL for navigation context */
parentPage?: string;
}
+/**
+ * Props for the MemoHeader component.
+ * Displays memo metadata like creator, timestamp, and action buttons.
+ */
export interface MemoHeaderProps {
// Display options
showCreator?: boolean;
@@ -27,6 +43,10 @@ export interface MemoHeaderProps {
onReactionSelectorOpenChange: (open: boolean) => void;
}
+/**
+ * Props for the MemoBody component.
+ * Displays memo content, attachments, and relations.
+ */
export interface MemoBodyProps {
// Display options
compact?: boolean;
@@ -35,36 +55,3 @@ export interface MemoBodyProps {
onContentDoubleClick: (e: React.MouseEvent) => void;
onToggleNsfwVisibility: () => void;
}
-
-export interface ImagePreviewState {
- open: boolean;
- urls: string[];
- index: number;
-}
-
-export interface UseMemoActionsReturn {
- archiveMemo: () => Promise;
- unpinMemo: () => Promise;
-}
-
-export interface UseKeyboardShortcutsOptions {
- enabled: boolean;
- readonly: boolean;
- showEditor: boolean;
- isArchived: boolean;
- onEdit: () => void;
- onArchive: () => Promise;
-}
-
-export interface UseNsfwContentReturn {
- nsfw: boolean;
- showNSFWContent: boolean;
- toggleNsfwVisibility: () => void;
-}
-
-export interface UseImagePreviewReturn {
- previewState: ImagePreviewState;
- openPreview: (url: string) => void;
- closePreview: () => void;
- setPreviewOpen: (open: boolean) => void;
-}