diff --git a/web/src/components/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx similarity index 56% rename from web/src/components/MemoActionMenu.tsx rename to web/src/components/MemoActionMenu/MemoActionMenu.tsx index 5f2f748d2..dc55b2ba4 100644 --- a/web/src/components/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -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 ( @@ -169,6 +78,7 @@ const MemoActionMenu = observer((props: Props) => { + {/* Edit actions (non-readonly, non-archived) */} {!readonly && !isArchived && ( <> {!isComment && ( @@ -183,6 +93,8 @@ const MemoActionMenu = observer((props: Props) => { )} + + {/* Copy submenu (non-archived) */} {!isArchived && ( @@ -201,20 +113,27 @@ const MemoActionMenu = observer((props: Props) => { )} + + {/* Write actions (non-readonly) */} {!readonly && ( <> + {/* Remove completed tasks (non-archived, non-comment, has completed tasks) */} {!isArchived && !isComment && hasCompletedTaskList && ( {t("memo.remove-completed-task-list-items")} )} + + {/* Archive/Restore (non-comment) */} {!isComment && ( {isArchived ? : } {isArchived ? t("common.restore") : t("common.archive")} )} + + {/* Delete */} {t("common.delete")} @@ -222,6 +141,7 @@ const MemoActionMenu = observer((props: Props) => { )} + {/* Delete confirmation dialog */} { onConfirm={confirmDeleteMemo} confirmVariant="destructive" /> + {/* Remove completed tasks confirmation */} 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, + }; +}; diff --git a/web/src/components/MemoActionMenu/index.ts b/web/src/components/MemoActionMenu/index.ts new file mode 100644 index 000000000..6a045be67 --- /dev/null +++ b/web/src/components/MemoActionMenu/index.ts @@ -0,0 +1,3 @@ +export { useMemoActionHandlers } from "./hooks"; +export { default, default as MemoActionMenu } from "./MemoActionMenu"; +export type { MemoActionMenuProps, UseMemoActionHandlersReturn } from "./types"; diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts new file mode 100644 index 000000000..c5e0ddbce --- /dev/null +++ b/web/src/components/MemoActionMenu/types.ts @@ -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; + handleEditMemoClick: () => void; + handleToggleMemoStatusClick: () => Promise; + handleCopyLink: () => void; + handleCopyContent: () => void; + handleDeleteMemoClick: () => void; + confirmDeleteMemo: () => Promise; + handleRemoveCompletedTaskListItemsClick: () => void; + confirmRemoveCompletedTaskListItems: () => Promise; +} diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts new file mode 100644 index 000000000..7b52eab2c --- /dev/null +++ b/web/src/components/MemoContent/constants.ts @@ -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" }, +}; diff --git a/web/src/components/MemoContent/hooks.ts b/web/src/components/MemoContent/hooks.ts new file mode 100644 index 000000000..2f7c82ca1 --- /dev/null +++ b/web/src/components/MemoContent/hooks.ts @@ -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(null); + const [mode, setMode] = useState(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); +}; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 0e5a18f8e..0f912e17e 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -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(null); - const [showCompactMode, setShowCompactMode] = useState(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 ( @@ -91,12 +48,12 @@ const MemoContent = observer((props: Props) => {
{ {content}
- {showCompactMode == "ALL" && ( -
+ {showCompactMode === "ALL" && ( +
)} - {showCompactMode != undefined && ( + {showCompactMode !== undefined && (
- { - setShowCompactMode(compactStates[showCompactMode].nextState as ContentCompactView); - }} +
)} diff --git a/web/src/components/MemoContent/types.ts b/web/src/components/MemoContent/types.ts new file mode 100644 index 000000000..00ddc5563 --- /dev/null +++ b/web/src/components/MemoContent/types.ts @@ -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"; diff --git a/web/src/components/MemoReactionListView.tsx b/web/src/components/MemoReactionListView.tsx deleted file mode 100644 index c9e67f503..000000000 --- a/web/src/components/MemoReactionListView.tsx +++ /dev/null @@ -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>(new Map()); - const readonly = memo.state === State.ARCHIVED; - - useEffect(() => { - (async () => { - const reactionGroup = new Map(); - 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 && ( -
- {Array.from(reactionGroup).map(([reactionType, users]) => { - return ; - })} - {!readonly && currentUser && } -
- ) - ); -}); - -export default memo(MemoReactionListView); diff --git a/web/src/components/MemoReactionListView/MemoReactionListView.tsx b/web/src/components/MemoReactionListView/MemoReactionListView.tsx new file mode 100644 index 000000000..77eb916aa --- /dev/null +++ b/web/src/components/MemoReactionListView/MemoReactionListView.tsx @@ -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 ( +
+ {Array.from(reactionGroup).map(([reactionType, users]) => ( + + ))} + {!readonly && currentUser && } +
+ ); +}); + +export default memo(MemoReactionListView); diff --git a/web/src/components/MemoReactionListView/hooks.ts b/web/src/components/MemoReactionListView/hooks.ts new file mode 100644 index 000000000..6bfbbf100 --- /dev/null +++ b/web/src/components/MemoReactionListView/hooks.ts @@ -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(new Map()); + + useEffect(() => { + const fetchReactionGroups = async () => { + const newReactionGroup = new Map(); + + 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; +}; diff --git a/web/src/components/MemoReactionListView/index.ts b/web/src/components/MemoReactionListView/index.ts new file mode 100644 index 000000000..4dfc8611b --- /dev/null +++ b/web/src/components/MemoReactionListView/index.ts @@ -0,0 +1,3 @@ +export { useReactionGroups } from "./hooks"; +export { default, default as MemoReactionListView } from "./MemoReactionListView"; +export type { MemoReactionListViewProps, ReactionGroup } from "./types"; diff --git a/web/src/components/MemoReactionListView/types.ts b/web/src/components/MemoReactionListView/types.ts new file mode 100644 index 000000000..bd5c717bf --- /dev/null +++ b/web/src/components/MemoReactionListView/types.ts @@ -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; diff --git a/web/src/components/MemoView.tsx b/web/src/components/MemoView.tsx deleted file mode 100644 index c7573af16..000000000 --- a/web/src/components/MemoView.tsx +++ /dev/null @@ -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 = 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(false); - const [creator, setCreator] = useState(userStore.getUserByName(memo.creator)); - const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent); - const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); - const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({ - open: false, - urls: [], - index: 0, - }); - const [shortcutActive, setShortcutActive] = useState(false); - const cardRef = useRef(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) - ) : ( - - ); - - return showEditor ? ( - setShowEditor(false)} - /> - ) : ( -
handleShortcutActivation(true)} - onBlur={() => handleShortcutActivation(false)} - > -
-
- {props.showCreator && creator ? ( -
- - - -
- - {creator.displayName || creator.username} - -
- {displayTime} -
-
-
- ) : ( -
- {displayTime} -
- )} -
-
- {currentUser && !isArchived && ( - - )} - {!isInMemoDetailPage && ( - - - {commentAmount > 0 && {commentAmount}} - - )} - {props.showVisibility && memo.visibility !== Visibility.PRIVATE && ( - - - - - - - {t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)} - - )} - {props.showPinned && memo.pinned && ( - - - - - - - - -

{t("common.unpin")}

-
-
-
- )} - {nsfw && showNSFWContent && ( - - setShowNSFWContent(false)} /> - - )} - setShowEditor(true)} /> -
-
-
- - {memo.location && } - - - -
- {nsfw && !showNSFWContent && ( - <> -
- - - )} - - setPreviewImage((prev) => ({ ...prev, open }))} - imgUrls={previewImage.urls} - initialIndex={previewImage.index} - /> -
- ); -}); - -export default memo(MemoView); diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx new file mode 100644 index 000000000..079282b75 --- /dev/null +++ b/web/src/components/MemoView/MemoView.tsx @@ -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 = observer((props: MemoViewProps) => { + const { memo: memoData, className } = props; + const location = useLocation(); + const navigateTo = useNavigateTo(); + const user = useCurrentUser(); + const cardRef = useRef(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 ( + + ); + } + + // Render memo card + return ( +
handleShortcutActivation(true)} + onBlur={() => handleShortcutActivation(false)} + > + setShowEditor(true)} + onGotoDetail={handleGotoMemoDetailPage} + onUnpin={unpinMemo} + onToggleNsfwVisibility={toggleNsfwVisibility} + nsfw={nsfw} + showNSFWContent={showNSFWContent} + reactionSelectorOpen={reactionSelectorOpen} + onReactionSelectorOpenChange={setReactionSelectorOpen} + /> + + + + +
+ ); +}); + +export default memo(MemoView); diff --git a/web/src/components/MemoView/components/MemoBody.tsx b/web/src/components/MemoView/components/MemoBody.tsx new file mode 100644 index 000000000..f87a635f6 --- /dev/null +++ b/web/src/components/MemoView/components/MemoBody.tsx @@ -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 = ({ + memo, + readonly, + compact, + parentPage, + nsfw, + showNSFWContent, + onContentClick, + onContentDoubleClick, + onToggleNsfwVisibility, +}) => { + const t = useTranslate(); + const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE); + + return ( + <> +
+ + {memo.location && } + + + +
+ + {/* NSFW content overlay */} + {nsfw && !showNSFWContent && ( + <> +
+ + + )} + + ); +}; + +export default MemoBody; diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx new file mode 100644 index 000000000..321572a53 --- /dev/null +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -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 = ({ + 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) + ) : ( + + ); + + return ( +
+ {/* Left section: Creator info or time */} +
+ {showCreator && creator ? ( + + ) : ( + + )} +
+ + {/* Right section: Actions */} +
+ {/* Reaction selector */} + {!isArchived && ( + + )} + + {/* Comment count link */} + {!isInMemoDetailPage && ( + + + {commentAmount > 0 && {commentAmount}} + + )} + + {/* Visibility icon */} + {showVisibility && memo.visibility !== Visibility.PRIVATE && ( + + + + + + + + {t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as Parameters[0])} + + + )} + + {/* Pinned indicator */} + {showPinned && memo.pinned && ( + + + + + + + + +

{t("common.unpin")}

+
+
+
+ )} + + {/* NSFW hide button */} + {nsfw && showNSFWContent && onToggleNsfwVisibility && ( + + + + )} + + {/* Action menu */} + +
+
+ ); +}; + +/** + * Creator display with avatar and name + */ +interface CreatorDisplayProps { + creator: NonNullable; + displayTime: React.ReactNode; + onGotoDetail: () => void; +} + +const CreatorDisplay: React.FC = ({ creator, displayTime, onGotoDetail }) => ( +
+ + + +
+ + {creator.displayName || creator.username} + + +
+
+); + +/** + * Simple time display without creator info + */ +interface TimeDisplayProps { + displayTime: React.ReactNode; + onGotoDetail: () => void; +} + +const TimeDisplay: React.FC = ({ displayTime, onGotoDetail }) => ( + +); + +export default MemoHeader; diff --git a/web/src/components/MemoView/components/index.ts b/web/src/components/MemoView/components/index.ts new file mode 100644 index 000000000..7e36dd8a1 --- /dev/null +++ b/web/src/components/MemoView/components/index.ts @@ -0,0 +1,2 @@ +export { default as MemoBody } from "./MemoBody"; +export { default as MemoHeader } from "./MemoHeader"; diff --git a/web/src/components/MemoView/constants.ts b/web/src/components/MemoView/constants.ts new file mode 100644 index 000000000..19f3a631d --- /dev/null +++ b/web/src/components/MemoView/constants.ts @@ -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; diff --git a/web/src/components/MemoView/hooks/index.ts b/web/src/components/MemoView/hooks/index.ts new file mode 100644 index 000000000..02f3b6195 --- /dev/null +++ b/web/src/components/MemoView/hooks/index.ts @@ -0,0 +1 @@ +export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState"; diff --git a/web/src/components/MemoView/hooks/useMemoViewState.ts b/web/src/components/MemoView/hooks/useMemoViewState.ts new file mode 100644 index 000000000..ef0a05d60 --- /dev/null +++ b/web/src/components/MemoView/hooks/useMemoViewState.ts @@ -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, + 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({ + 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; +}; diff --git a/web/src/components/MemoView/index.ts b/web/src/components/MemoView/index.ts new file mode 100644 index 000000000..ee160abfe --- /dev/null +++ b/web/src/components/MemoView/index.ts @@ -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"; diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts new file mode 100644 index 000000000..5b50b87cf --- /dev/null +++ b/web/src/components/MemoView/types.ts @@ -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; + unpinMemo: () => Promise; +} + +/** + * Return type for useKeyboardShortcuts hook + */ +export interface UseKeyboardShortcutsOptions { + enabled: boolean; + readonly: boolean; + showEditor: boolean; + isArchived: boolean; + onEdit: () => void; + onArchive: () => Promise; +} + +/** + * 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; +} diff --git a/web/src/components/ReactionSelector.tsx b/web/src/components/ReactionSelector.tsx deleted file mode 100644 index e4ede3993..000000000 --- a/web/src/components/ReactionSelector.tsx +++ /dev/null @@ -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(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 ( - - - - - - - -
-
- {instanceMemoRelatedSetting.reactions.map((reactionType) => { - return ( - handleReactionClick(reactionType)} - > - {reactionType} - - ); - })} -
-
-
-
- ); -}); - -export default ReactionSelector; diff --git a/web/src/components/ReactionView.tsx b/web/src/components/ReactionView.tsx deleted file mode 100644 index ea64eff2e..000000000 --- a/web/src/components/ReactionView.tsx +++ /dev/null @@ -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 ( - - - -
- {reactionType} - {users.length} -
-
- -

{stringifyUsers(users, reactionType)}

-
-
-
- ); -}); - -export default ReactionView; diff --git a/web/src/components/reactions/ReactionSelector.tsx b/web/src/components/reactions/ReactionSelector.tsx new file mode 100644 index 000000000..a152e937b --- /dev/null +++ b/web/src/components/reactions/ReactionSelector.tsx @@ -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 ( + + + + + + + +
+ {instanceMemoRelatedSetting.reactions.map((reactionType) => ( + + ))} +
+
+
+ ); +}); + +export default ReactionSelector; diff --git a/web/src/components/reactions/ReactionView.tsx b/web/src/components/reactions/ReactionView.tsx new file mode 100644 index 000000000..16ce947e6 --- /dev/null +++ b/web/src/components/reactions/ReactionView.tsx @@ -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 ( + + + + + + +

{formatReactionTooltip(users, reactionType)}

+
+
+
+ ); +}); + +export default ReactionView; diff --git a/web/src/components/reactions/hooks.ts b/web/src/components/reactions/hooks.ts new file mode 100644 index 000000000..21af37790 --- /dev/null +++ b/web/src/components/reactions/hooks.ts @@ -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()}`; +}; diff --git a/web/src/components/reactions/index.ts b/web/src/components/reactions/index.ts new file mode 100644 index 000000000..6898725f9 --- /dev/null +++ b/web/src/components/reactions/index.ts @@ -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"; diff --git a/web/src/components/reactions/types.ts b/web/src/components/reactions/types.ts new file mode 100644 index 000000000..9e97ed4c7 --- /dev/null +++ b/web/src/components/reactions/types.ts @@ -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[]; +}