refactor(MemoView): improve maintainability and code organization

Complete refactoring of MemoView components for better code quality:

- Split useMemoViewState into individual hook files (useMemoActions,
  useKeyboardShortcuts, useNsfwContent, useImagePreview, useMemoCreator)
  for single responsibility principle
- Consolidate types closer to usage - move hook-specific types to
  respective files, keep only component props in shared types.ts
- Optimize context with separate static/dynamic memoization to reduce
  unnecessary re-renders
- Simplify barrel exports to only expose public API (MemoView component
  and MemoViewProps type)
- Add comprehensive JSDoc documentation to all public APIs with usage
  examples

Benefits:
- Better maintainability: each hook file has one clear purpose
- Improved performance: context optimization prevents unnecessary re-renders
- Enhanced developer experience: clear documentation and encapsulation
- Cleaner architecture: public API is minimal, internal details hidden

All automated checks pass (TypeScript compilation, linter, production build).
This commit is contained in:
Johnny 2025-12-23 19:01:57 +08:00
parent 1b11e8c841
commit e61d594ded
11 changed files with 214 additions and 145 deletions

View File

@ -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
* <MemoView
* memo={memoData}
* showCreator
* showVisibility
* compact={false}
* />
* ```
*/
const MemoView: React.FC<Props> = observer((props: Props) => {
const { memo: memoData, className } = props;
const cardRef = useRef<HTMLDivElement>(null);
@ -59,7 +80,8 @@ const MemoView: React.FC<Props> = 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<Props> = 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) {

View File

@ -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<MemoViewContextValue | null>(null);
export const useMemoViewContext = (): MemoViewContextValue => {

View File

@ -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";

View File

@ -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<ImagePreviewState>({ 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 })),
};
};

View File

@ -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<void>;
}
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<HTMLDivElement | null>, 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]);
};

View File

@ -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 };
};

View File

@ -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;
};

View File

@ -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<HTMLDivElement | null>, 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<ImagePreviewState>({ 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;
};

View File

@ -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),
};
};

View File

@ -1,3 +1,2 @@
export { MemoBody, MemoHeader } from "./components";
export * from "./constants";
export { default, default as MemoView } from "./MemoView";
export type { MemoViewProps } from "./types";

View File

@ -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<void>;
unpinMemo: () => Promise<void>;
}
export interface UseKeyboardShortcutsOptions {
enabled: boolean;
readonly: boolean;
showEditor: boolean;
isArchived: boolean;
onEdit: () => void;
onArchive: () => Promise<void>;
}
export interface UseNsfwContentReturn {
nsfw: boolean;
showNSFWContent: boolean;
toggleNsfwVisibility: () => void;
}
export interface UseImagePreviewReturn {
previewState: ImagePreviewState;
openPreview: (url: string) => void;
closePreview: () => void;
setPreviewOpen: (open: boolean) => void;
}