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