mirror of https://github.com/usememos/memos.git
refactor: implement MemoView component with subcomponents and hooks
- Added MemoView component to display a single memo card with full functionality including creator info, memo content, attachments, reactions, and comments. - Created MemoBody and MemoHeader subcomponents to separate concerns and improve maintainability. - Introduced custom hooks for managing memo actions, keyboard shortcuts, NSFW content visibility, and image preview. - Implemented reaction handling with new ReactionSelector and ReactionView components. - Added TypeScript types for better type safety and clarity. - Established constants for memo card styling and keyboard shortcuts. - Removed legacy ReactionSelector and ReactionView components from the previous structure.
This commit is contained in:
parent
50199fe998
commit
1ef11f7470
|
|
@ -1,4 +1,3 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
|
|
@ -14,16 +13,8 @@ import {
|
|||
} from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { hasCompletedTasks, removeCompletedTasks } from "@/utils/markdown-manipulation";
|
||||
import { Button } from "./ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -32,134 +23,52 @@ import {
|
|||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { hasCompletedTasks } from "@/utils/markdown-manipulation";
|
||||
import { useMemoActionHandlers } from "./hooks";
|
||||
import type { MemoActionMenuProps } from "./types";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
const checkHasCompletedTaskList = (memo: Memo) => {
|
||||
return hasCompletedTasks(memo.content);
|
||||
};
|
||||
|
||||
const MemoActionMenu = observer((props: Props) => {
|
||||
/**
|
||||
* MemoActionMenu component provides a dropdown menu with actions for a memo:
|
||||
* - Pin/Unpin
|
||||
* - Edit
|
||||
* - Copy (link/content)
|
||||
* - Remove completed tasks
|
||||
* - Archive/Restore
|
||||
* - Delete
|
||||
*/
|
||||
const MemoActionMenu = observer((props: MemoActionMenuProps) => {
|
||||
const { memo, readonly } = props;
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
|
||||
// Dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = useState(false);
|
||||
const hasCompletedTaskList = checkHasCompletedTaskList(memo);
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
|
||||
// Derived state
|
||||
const hasCompletedTaskList = hasCompletedTasks(memo.content);
|
||||
const isComment = Boolean(memo.parent);
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
|
||||
const memoUpdatedCallback = () => {
|
||||
// Refresh user stats.
|
||||
userStore.setStatsStateId();
|
||||
};
|
||||
|
||||
const handleTogglePinMemoBtnClick = async () => {
|
||||
try {
|
||||
if (memo.pinned) {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
pinned: false,
|
||||
},
|
||||
["pinned"],
|
||||
);
|
||||
} else {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
pinned: true,
|
||||
},
|
||||
["pinned"],
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// do nth
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditMemoClick = () => {
|
||||
if (props.onEdit) {
|
||||
props.onEdit();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMemoStatusClick = async () => {
|
||||
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
|
||||
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
state,
|
||||
},
|
||||
["state"],
|
||||
);
|
||||
toast.success(message);
|
||||
} catch (error: any) {
|
||||
toast.error(error.details);
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
let host = instanceStore.state.profile.instanceUrl;
|
||||
if (host === "") {
|
||||
host = window.location.origin;
|
||||
}
|
||||
copy(`${host}/${memo.name}`);
|
||||
toast.success(t("message.succeed-copy-link"));
|
||||
};
|
||||
|
||||
const handleCopyContent = () => {
|
||||
copy(memo.content);
|
||||
toast.success(t("message.succeed-copy-content"));
|
||||
};
|
||||
|
||||
const handleDeleteMemoClick = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteMemo = async () => {
|
||||
await memoStore.deleteMemo(memo.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
};
|
||||
|
||||
const handleRemoveCompletedTaskListItemsClick = () => {
|
||||
setRemoveTasksDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmRemoveCompletedTaskListItems = async () => {
|
||||
const newContent = removeCompletedTasks(memo.content);
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
toast.success(t("message.remove-completed-task-list-items-successfully"));
|
||||
memoUpdatedCallback();
|
||||
};
|
||||
// Action handlers
|
||||
const {
|
||||
handleTogglePinMemoBtnClick,
|
||||
handleEditMemoClick,
|
||||
handleToggleMemoStatusClick,
|
||||
handleCopyLink,
|
||||
handleCopyContent,
|
||||
handleDeleteMemoClick,
|
||||
confirmDeleteMemo,
|
||||
handleRemoveCompletedTaskListItemsClick,
|
||||
confirmRemoveCompletedTaskListItems,
|
||||
} = useMemoActionHandlers({
|
||||
memo,
|
||||
onEdit: props.onEdit,
|
||||
setDeleteDialogOpen,
|
||||
setRemoveTasksDialogOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
|
@ -169,6 +78,7 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2}>
|
||||
{/* Edit actions (non-readonly, non-archived) */}
|
||||
{!readonly && !isArchived && (
|
||||
<>
|
||||
{!isComment && (
|
||||
|
|
@ -183,6 +93,8 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Copy submenu (non-archived) */}
|
||||
{!isArchived && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
|
|
@ -201,20 +113,27 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{/* Write actions (non-readonly) */}
|
||||
{!readonly && (
|
||||
<>
|
||||
{/* Remove completed tasks (non-archived, non-comment, has completed tasks) */}
|
||||
{!isArchived && !isComment && hasCompletedTaskList && (
|
||||
<DropdownMenuItem onClick={handleRemoveCompletedTaskListItemsClick}>
|
||||
<SquareCheckIcon className="w-4 h-auto" />
|
||||
{t("memo.remove-completed-task-list-items")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Archive/Restore (non-comment) */}
|
||||
{!isComment && (
|
||||
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
|
||||
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
|
||||
{isArchived ? t("common.restore") : t("common.archive")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem onClick={handleDeleteMemoClick}>
|
||||
<TrashIcon className="w-4 h-auto" />
|
||||
{t("common.delete")}
|
||||
|
|
@ -222,6 +141,7 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
|
|
@ -233,6 +153,7 @@ const MemoActionMenu = observer((props: Props) => {
|
|||
onConfirm={confirmDeleteMemo}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
|
||||
{/* Remove completed tasks confirmation */}
|
||||
<ConfirmDialog
|
||||
open={removeTasksDialogOpen}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import { useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { removeCompletedTasks } from "@/utils/markdown-manipulation";
|
||||
|
||||
interface UseMemoActionHandlersOptions {
|
||||
memo: Memo;
|
||||
onEdit?: () => void;
|
||||
setDeleteDialogOpen: (open: boolean) => void;
|
||||
setRemoveTasksDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling memo action menu operations
|
||||
*/
|
||||
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRemoveTasksDialogOpen }: UseMemoActionHandlersOptions) => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
|
||||
const memoUpdatedCallback = useCallback(() => {
|
||||
userStore.setStatsStateId();
|
||||
}, []);
|
||||
|
||||
const handleTogglePinMemoBtnClick = useCallback(async () => {
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
pinned: !memo.pinned,
|
||||
},
|
||||
["pinned"],
|
||||
);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}, [memo.name, memo.pinned]);
|
||||
|
||||
const handleEditMemoClick = useCallback(() => {
|
||||
onEdit?.();
|
||||
}, [onEdit]);
|
||||
|
||||
const handleToggleMemoStatusClick = useCallback(async () => {
|
||||
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
|
||||
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
|
||||
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
state,
|
||||
},
|
||||
["state"],
|
||||
);
|
||||
toast.success(message);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { details?: string };
|
||||
toast.error(err.details || "An error occurred");
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
let host = instanceStore.state.profile.instanceUrl;
|
||||
if (host === "") {
|
||||
host = window.location.origin;
|
||||
}
|
||||
copy(`${host}/${memo.name}`);
|
||||
toast.success(t("message.succeed-copy-link"));
|
||||
}, [memo.name, t]);
|
||||
|
||||
const handleCopyContent = useCallback(() => {
|
||||
copy(memo.content);
|
||||
toast.success(t("message.succeed-copy-content"));
|
||||
}, [memo.content, t]);
|
||||
|
||||
const handleDeleteMemoClick = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, [setDeleteDialogOpen]);
|
||||
|
||||
const confirmDeleteMemo = useCallback(async () => {
|
||||
await memoStore.deleteMemo(memo.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]);
|
||||
|
||||
const handleRemoveCompletedTaskListItemsClick = useCallback(() => {
|
||||
setRemoveTasksDialogOpen(true);
|
||||
}, [setRemoveTasksDialogOpen]);
|
||||
|
||||
const confirmRemoveCompletedTaskListItems = useCallback(async () => {
|
||||
const newContent = removeCompletedTasks(memo.content);
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
toast.success(t("message.remove-completed-task-list-items-successfully"));
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.content, t, memoUpdatedCallback]);
|
||||
|
||||
return {
|
||||
handleTogglePinMemoBtnClick,
|
||||
handleEditMemoClick,
|
||||
handleToggleMemoStatusClick,
|
||||
handleCopyLink,
|
||||
handleCopyContent,
|
||||
handleDeleteMemoClick,
|
||||
confirmDeleteMemo,
|
||||
handleRemoveCompletedTaskListItemsClick,
|
||||
confirmRemoveCompletedTaskListItems,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { useMemoActionHandlers } from "./hooks";
|
||||
export { default, default as MemoActionMenu } from "./MemoActionMenu";
|
||||
export type { MemoActionMenuProps, UseMemoActionHandlersReturn } from "./types";
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
|
||||
/**
|
||||
* Props for MemoActionMenu component
|
||||
*/
|
||||
export interface MemoActionMenuProps {
|
||||
/** The memo to display actions for */
|
||||
memo: Memo;
|
||||
/** Whether the current user can only view (not edit) */
|
||||
readonly?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Callback when edit action is triggered */
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useMemoActionHandlers hook
|
||||
*/
|
||||
export interface UseMemoActionHandlersReturn {
|
||||
handleTogglePinMemoBtnClick: () => Promise<void>;
|
||||
handleEditMemoClick: () => void;
|
||||
handleToggleMemoStatusClick: () => Promise<void>;
|
||||
handleCopyLink: () => void;
|
||||
handleCopyContent: () => void;
|
||||
handleDeleteMemoClick: () => void;
|
||||
confirmDeleteMemo: () => Promise<void>;
|
||||
handleRemoveCompletedTaskListItemsClick: () => void;
|
||||
confirmRemoveCompletedTaskListItems: () => Promise<void>;
|
||||
}
|
||||
|
|
@ -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" },
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { COMPACT_STATES, MAX_DISPLAY_HEIGHT } from "./constants";
|
||||
import type { ContentCompactView } from "./types";
|
||||
|
||||
export const useCompactMode = (enabled: boolean) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) return;
|
||||
if (containerRef.current.getBoundingClientRect().height > MAX_DISPLAY_HEIGHT) {
|
||||
setMode("ALL");
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!mode) return;
|
||||
setMode(COMPACT_STATES[mode].next);
|
||||
}, [mode]);
|
||||
|
||||
return { containerRef, mode, toggle };
|
||||
};
|
||||
|
||||
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
|
||||
if (!mode) return "";
|
||||
return t(COMPACT_STATES[mode].textKey);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
|
|
@ -13,34 +13,21 @@ import { remarkTag } from "@/utils/remark-plugins/remark-tag";
|
|||
import { isSuperUser } from "@/utils/user";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
|
||||
import { useCompactLabel, useCompactMode } from "./hooks";
|
||||
import { MemoContentContext } from "./MemoContentContext";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
import type { MemoContentProps } from "./types";
|
||||
|
||||
// MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode.
|
||||
const MAX_DISPLAY_HEIGHT = 256;
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
memoName?: string;
|
||||
compact?: boolean;
|
||||
readonly?: boolean;
|
||||
disableFilter?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
type ContentCompactView = "ALL" | "SNIPPET";
|
||||
|
||||
const MemoContent = observer((props: Props) => {
|
||||
const MemoContent = observer((props: MemoContentProps) => {
|
||||
const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const memoContentContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showCompactMode, setShowCompactMode] = useState<ContentCompactView | undefined>(undefined);
|
||||
const {
|
||||
containerRef: memoContentContainerRef,
|
||||
mode: showCompactMode,
|
||||
toggle: toggleCompactMode,
|
||||
} = useCompactMode(Boolean(props.compact));
|
||||
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
|
||||
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
|
||||
|
||||
|
|
@ -53,37 +40,7 @@ const MemoContent = observer((props: Props) => {
|
|||
containerRef: memoContentContainerRef,
|
||||
};
|
||||
|
||||
// Initial compact mode.
|
||||
useEffect(() => {
|
||||
if (!props.compact) {
|
||||
return;
|
||||
}
|
||||
if (!memoContentContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((memoContentContainerRef.current as HTMLDivElement).getBoundingClientRect().height > MAX_DISPLAY_HEIGHT) {
|
||||
setShowCompactMode("ALL");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onMemoContentClick = async (e: React.MouseEvent) => {
|
||||
// Image clicks and other handlers
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const onMemoContentDoubleClick = async (e: React.MouseEvent) => {
|
||||
if (onDoubleClick) {
|
||||
onDoubleClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const compactStates = {
|
||||
ALL: { text: t("memo.show-more"), nextState: "SNIPPET" },
|
||||
SNIPPET: { text: t("memo.show-less"), nextState: "ALL" },
|
||||
};
|
||||
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
|
||||
|
||||
return (
|
||||
<MemoContentContext.Provider value={contextValue}>
|
||||
|
|
@ -91,12 +48,12 @@ const MemoContent = observer((props: Props) => {
|
|||
<div
|
||||
ref={memoContentContainerRef}
|
||||
className={cn(
|
||||
"markdown-content relative w-full max-w-full break-words text-base leading-6",
|
||||
showCompactMode == "ALL" && "line-clamp-6 max-h-60",
|
||||
"markdown-content relative w-full max-w-full wrap-break-word text-base leading-6",
|
||||
showCompactMode === "ALL" && "line-clamp-6 max-h-60",
|
||||
contentClassName,
|
||||
)}
|
||||
onClick={onMemoContentClick}
|
||||
onDoubleClick={onMemoContentDoubleClick}
|
||||
onMouseUp={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks, remarkTag, remarkPreserveType]}
|
||||
|
|
@ -116,19 +73,18 @@ const MemoContent = observer((props: Props) => {
|
|||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{showCompactMode == "ALL" && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-12 bg-gradient-to-b from-transparent to-background pointer-events-none"></div>
|
||||
{showCompactMode === "ALL" && (
|
||||
<div className="absolute bottom-0 left-0 w-full h-12 bg-linear-to-b from-transparent to-background pointer-events-none"></div>
|
||||
)}
|
||||
{showCompactMode != undefined && (
|
||||
{showCompactMode !== undefined && (
|
||||
<div className="w-full mt-1">
|
||||
<span
|
||||
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80"
|
||||
onClick={() => {
|
||||
setShowCompactMode(compactStates[showCompactMode].nextState as ContentCompactView);
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80 text-left"
|
||||
onClick={toggleCompactMode}
|
||||
>
|
||||
{compactStates[showCompactMode].text}
|
||||
</span>
|
||||
{compactLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { uniq } from "lodash-es";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { Memo, Reaction } from "@/types/proto/api/v1/memo_service";
|
||||
import { User } from "@/types/proto/api/v1/user_service";
|
||||
import ReactionSelector from "./ReactionSelector";
|
||||
import ReactionView from "./ReactionView";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
const MemoReactionListView = observer((props: Props) => {
|
||||
const { memo, reactions } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const [reactionGroup, setReactionGroup] = useState<Map<string, User[]>>(new Map());
|
||||
const readonly = memo.state === State.ARCHIVED;
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const reactionGroup = new Map<string, User[]>();
|
||||
for (const reaction of reactions) {
|
||||
const user = await userStore.getOrFetchUserByName(reaction.creator);
|
||||
const users = reactionGroup.get(reaction.reactionType) || [];
|
||||
users.push(user);
|
||||
reactionGroup.set(reaction.reactionType, uniq(users));
|
||||
}
|
||||
setReactionGroup(reactionGroup);
|
||||
})();
|
||||
}, [reactions]);
|
||||
|
||||
return (
|
||||
reactions.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap gap-1 select-none">
|
||||
{Array.from(reactionGroup).map(([reactionType, users]) => {
|
||||
return <ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memo} reactionType={reactionType} users={users} />;
|
||||
})}
|
||||
{!readonly && currentUser && <ReactionSelector memo={memo} />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(MemoReactionListView);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo } from "react";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { ReactionSelector, ReactionView } from "../reactions";
|
||||
import { useReactionGroups } from "./hooks";
|
||||
import type { MemoReactionListViewProps } from "./types";
|
||||
|
||||
/**
|
||||
* MemoReactionListView displays the reactions on a memo:
|
||||
* - Groups reactions by type
|
||||
* - Shows reaction emoji with count
|
||||
* - Allows adding new reactions (if not readonly)
|
||||
*/
|
||||
const MemoReactionListView = observer((props: MemoReactionListViewProps) => {
|
||||
const { memo: memoData, reactions } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const reactionGroup = useReactionGroups(reactions);
|
||||
const readonly = memoData.state === State.ARCHIVED;
|
||||
|
||||
if (reactions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-start items-start flex-wrap gap-1 select-none">
|
||||
{Array.from(reactionGroup).map(([reactionType, users]) => (
|
||||
<ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memoData} reactionType={reactionType} users={users} />
|
||||
))}
|
||||
{!readonly && currentUser && <ReactionSelector memo={memoData} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(MemoReactionListView);
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { uniq } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { userStore } from "@/store";
|
||||
import type { Reaction } from "@/types/proto/api/v1/memo_service";
|
||||
import type { User } from "@/types/proto/api/v1/user_service";
|
||||
import type { ReactionGroup } from "./types";
|
||||
|
||||
/**
|
||||
* Hook for grouping reactions by type and fetching user data
|
||||
*/
|
||||
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
|
||||
const [reactionGroup, setReactionGroup] = useState<ReactionGroup>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const fetchReactionGroups = async () => {
|
||||
const newReactionGroup = new Map<string, User[]>();
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const user = await userStore.getOrFetchUserByName(reaction.creator);
|
||||
const users = newReactionGroup.get(reaction.reactionType) || [];
|
||||
users.push(user);
|
||||
newReactionGroup.set(reaction.reactionType, uniq(users));
|
||||
}
|
||||
|
||||
setReactionGroup(newReactionGroup);
|
||||
};
|
||||
|
||||
fetchReactionGroups();
|
||||
}, [reactions]);
|
||||
|
||||
return reactionGroup;
|
||||
};
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { useReactionGroups } from "./hooks";
|
||||
export { default, default as MemoReactionListView } from "./MemoReactionListView";
|
||||
export type { MemoReactionListViewProps, ReactionGroup } from "./types";
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service";
|
||||
import type { User } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
/**
|
||||
* Props for MemoReactionListView component
|
||||
*/
|
||||
export interface MemoReactionListViewProps {
|
||||
/** The memo that reactions belong to */
|
||||
memo: Memo;
|
||||
/** List of reactions to display */
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped reactions with users who reacted
|
||||
*/
|
||||
export type ReactionGroup = Map<string, User[]>;
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { Memo, MemoRelation_Type, Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityToString } from "@/utils/memo";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoActionMenu from "./MemoActionMenu";
|
||||
import MemoContent from "./MemoContent";
|
||||
import MemoEditor from "./MemoEditor";
|
||||
import MemoReactionistView from "./MemoReactionListView";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "./memo-metadata";
|
||||
import PreviewImageDialog from "./PreviewImageDialog";
|
||||
import ReactionSelector from "./ReactionSelector";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import VisibilityIcon from "./VisibilityIcon";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
compact?: boolean;
|
||||
showCreator?: boolean;
|
||||
showVisibility?: boolean;
|
||||
showPinned?: boolean;
|
||||
showNsfwContent?: boolean;
|
||||
className?: string;
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
const MemoView: React.FC<Props> = observer((props: Props) => {
|
||||
const { memo, className } = props;
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const currentUser = useCurrentUser();
|
||||
const user = useCurrentUser();
|
||||
const [showEditor, setShowEditor] = useState<boolean>(false);
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(memo.creator));
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(props.showNsfwContent);
|
||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState<boolean>(false);
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
const [shortcutActive, setShortcutActive] = useState(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
const commentAmount = memo.relations.filter(
|
||||
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memo.name,
|
||||
).length;
|
||||
const relativeTimeFormat = Date.now() - memo.displayTime!.getTime() > 1000 * 60 * 60 * 24 ? "datetime" : "auto";
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
const readonly = memo.creator !== user?.name && !isSuperUser(user);
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
const parentPage = props.parentPage || location.pathname;
|
||||
const nsfw =
|
||||
instanceMemoRelatedSetting.enableBlurNsfwContent &&
|
||||
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
|
||||
|
||||
// Initial related data: creator.
|
||||
useAsyncEffect(async () => {
|
||||
const user = await userStore.getOrFetchUserByName(memo.creator);
|
||||
setCreator(user);
|
||||
}, []);
|
||||
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
navigateTo(`/${memo.name}`, {
|
||||
state: {
|
||||
from: parentPage,
|
||||
},
|
||||
});
|
||||
}, [memo.name, parentPage]);
|
||||
|
||||
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.tagName === "IMG") {
|
||||
// Check if the image is inside a link
|
||||
const linkElement = targetEl.closest("a");
|
||||
if (linkElement) {
|
||||
// If image is inside a link, only navigate to the link (don't show preview)
|
||||
return;
|
||||
}
|
||||
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) {
|
||||
setPreviewImage({ open: true, urls: [imgUrl], index: 0 });
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMemoContentDoubleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
|
||||
e.preventDefault();
|
||||
setShowEditor(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onEditorConfirm = () => {
|
||||
setShowEditor(false);
|
||||
userStore.setStatsStateId();
|
||||
};
|
||||
|
||||
const onPinIconClick = async () => {
|
||||
if (memo.pinned) {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
pinned: false,
|
||||
},
|
||||
["pinned"],
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const archiveMemo = useCallback(async () => {
|
||||
if (isArchived) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
state: State.ARCHIVED,
|
||||
},
|
||||
["state"],
|
||||
);
|
||||
toast.success(t("message.archived-successfully"));
|
||||
userStore.setStatsStateId();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error?.details);
|
||||
}
|
||||
}, [isArchived, memo.name, t, memoStore, userStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shortcutActive || readonly || showEditor || !cardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardEl = cardRef.current;
|
||||
const isTextInputElement = (element: HTMLElement | null) => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
if (element.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
if (element instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element instanceof HTMLInputElement) {
|
||||
const textTypes = ["text", "search", "email", "password", "url", "tel", "number"];
|
||||
return textTypes.includes(element.type || "text");
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!cardEl.contains(target) || isTextInputElement(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "e") {
|
||||
event.preventDefault();
|
||||
setShowEditor(true);
|
||||
} else if (key === "a" && !isArchived) {
|
||||
event.preventDefault();
|
||||
archiveMemo();
|
||||
}
|
||||
};
|
||||
|
||||
cardEl.addEventListener("keydown", handleKeyDown);
|
||||
return () => cardEl.removeEventListener("keydown", handleKeyDown);
|
||||
}, [shortcutActive, readonly, showEditor, isArchived, archiveMemo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showEditor || readonly) {
|
||||
setShortcutActive(false);
|
||||
}
|
||||
}, [showEditor, readonly]);
|
||||
|
||||
const handleShortcutActivation = (active: boolean) => {
|
||||
if (readonly) {
|
||||
return;
|
||||
}
|
||||
setShortcutActive(active);
|
||||
};
|
||||
|
||||
const displayTime = isArchived ? (
|
||||
memo.displayTime?.toLocaleString(i18n.language)
|
||||
) : (
|
||||
<relative-time datetime={memo.displayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat}></relative-time>
|
||||
);
|
||||
|
||||
return showEditor ? (
|
||||
<MemoEditor
|
||||
autoFocus
|
||||
className="mb-2"
|
||||
cacheKey={`inline-memo-editor-${memo.name}`}
|
||||
memoName={memo.name}
|
||||
onConfirm={onEditorConfirm}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors",
|
||||
className,
|
||||
)}
|
||||
ref={cardRef}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
onFocus={() => handleShortcutActivation(true)}
|
||||
onBlur={() => handleShortcutActivation(false)}
|
||||
>
|
||||
<div className="w-full flex flex-row justify-between items-center gap-2">
|
||||
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
||||
{props.showCreator && creator ? (
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<Link
|
||||
className="w-auto hover:opacity-80 rounded-md transition-colors"
|
||||
to={`/u/${encodeURIComponent(creator.username)}`}
|
||||
viewTransition
|
||||
>
|
||||
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
|
||||
</Link>
|
||||
<div className="w-full flex flex-col justify-center items-start">
|
||||
<Link
|
||||
className="block leading-tight hover:opacity-80 rounded-md transition-colors truncate text-muted-foreground"
|
||||
to={`/u/${encodeURIComponent(creator.username)}`}
|
||||
viewTransition
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
</Link>
|
||||
<div
|
||||
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors"
|
||||
onClick={handleGotoMemoDetailPage}
|
||||
>
|
||||
{displayTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={handleGotoMemoDetailPage}
|
||||
>
|
||||
{displayTime}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
||||
{currentUser && !isArchived && (
|
||||
<ReactionSelector
|
||||
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "!block", "hidden group-hover:block")}
|
||||
memo={memo}
|
||||
onOpenChange={setReactionSelectorOpen}
|
||||
/>
|
||||
)}
|
||||
{!isInMemoDetailPage && (
|
||||
<Link
|
||||
className={cn(
|
||||
"flex flex-row justify-start items-center rounded-md p-1 hover:opacity-80",
|
||||
commentAmount === 0 && "invisible group-hover:visible",
|
||||
)}
|
||||
to={`/${memo.name}#comments`}
|
||||
viewTransition
|
||||
state={{
|
||||
from: parentPage,
|
||||
}}
|
||||
>
|
||||
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-muted-foreground" />
|
||||
{commentAmount > 0 && <span className="text-xs text-muted-foreground">{commentAmount}</span>}
|
||||
</Link>
|
||||
)}
|
||||
{props.showVisibility && memo.visibility !== Visibility.PRIVATE && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="flex justify-center items-center rounded-md hover:opacity-80">
|
||||
<VisibilityIcon visibility={memo.visibility} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as any)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{props.showPinned && memo.pinned && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-pointer">
|
||||
<BookmarkIcon className="w-4 h-auto text-primary" onClick={onPinIconClick} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.unpin")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{nsfw && showNSFWContent && (
|
||||
<span className="cursor-pointer">
|
||||
<EyeOffIcon className="w-4 h-auto text-primary" onClick={() => setShowNSFWContent(false)} />
|
||||
</span>
|
||||
)}
|
||||
<MemoActionMenu memo={memo} readonly={readonly} onEdit={() => setShowEditor(true)} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex flex-col justify-start items-start gap-2",
|
||||
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
|
||||
)}
|
||||
>
|
||||
<MemoContent
|
||||
key={`${memo.name}-${memo.updateTime}`}
|
||||
memoName={memo.name}
|
||||
content={memo.content}
|
||||
readonly={readonly}
|
||||
onClick={handleMemoContentClick}
|
||||
onDoubleClick={handleMemoContentDoubleClick}
|
||||
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
|
||||
parentPage={parentPage}
|
||||
/>
|
||||
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
|
||||
<AttachmentList mode="view" attachments={memo.attachments} />
|
||||
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
||||
<MemoReactionistView memo={memo} reactions={memo.reactions} />
|
||||
</div>
|
||||
{nsfw && !showNSFWContent && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-transparent" />
|
||||
<button
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-muted-foreground hover:text-foreground hover:bg-accent hover:border-accent border border-border rounded-lg bg-card transition-colors"
|
||||
onClick={() => setShowNSFWContent(true)}
|
||||
>
|
||||
{t("memo.click-to-show-nsfw-content")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(MemoView);
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoEditor from "../MemoEditor";
|
||||
import PreviewImageDialog from "../PreviewImageDialog";
|
||||
import { MemoBody, MemoHeader } from "./components";
|
||||
import { MEMO_CARD_BASE_CLASSES, RELATIVE_TIME_THRESHOLD_MS } from "./constants";
|
||||
import { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./hooks";
|
||||
import type { MemoViewProps } from "./types";
|
||||
|
||||
/**
|
||||
* MemoView component displays a single memo card with full functionality including:
|
||||
* - Creator information and display time
|
||||
* - Memo content with markdown rendering
|
||||
* - Attachments and location
|
||||
* - Reactions and comments
|
||||
* - Edit mode with inline editor
|
||||
* - Keyboard shortcuts for quick actions
|
||||
* - NSFW content blur protection
|
||||
*/
|
||||
const MemoView: React.FC<MemoViewProps> = observer((props: MemoViewProps) => {
|
||||
const { memo: memoData, className } = props;
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const user = useCurrentUser();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||
|
||||
// Fetch creator data
|
||||
const creator = useMemoCreator(memoData.creator);
|
||||
|
||||
// Custom hooks for state management
|
||||
const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent);
|
||||
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
||||
const { archiveMemo, unpinMemo } = useMemoActions(memoData);
|
||||
|
||||
// Derived state
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
const commentAmount = memoData.relations.filter(
|
||||
(relation) => relation.type === MemoRelation_Type.COMMENT && relation.relatedMemo?.name === memoData.name,
|
||||
).length;
|
||||
const relativeTimeFormat =
|
||||
memoData.displayTime && Date.now() - memoData.displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto";
|
||||
const isArchived = memoData.state === State.ARCHIVED;
|
||||
const readonly = memoData.creator !== user?.name && !isSuperUser(user);
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
|
||||
const parentPage = props.parentPage || location.pathname;
|
||||
|
||||
// Keyboard shortcuts
|
||||
const { handleShortcutActivation } = useKeyboardShortcuts(cardRef, {
|
||||
enabled: true,
|
||||
readonly,
|
||||
showEditor,
|
||||
isArchived,
|
||||
onEdit: () => setShowEditor(true),
|
||||
onArchive: archiveMemo,
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
navigateTo(`/${memoData.name}`, {
|
||||
state: { from: parentPage },
|
||||
});
|
||||
}, [memoData.name, parentPage, navigateTo]);
|
||||
|
||||
const handleMemoContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.tagName === "IMG") {
|
||||
// Check if the image is inside a link
|
||||
const linkElement = targetEl.closest("a");
|
||||
if (linkElement) {
|
||||
// If image is inside a link, only navigate to the link (don't show preview)
|
||||
return;
|
||||
}
|
||||
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) {
|
||||
openPreview(imgUrl);
|
||||
}
|
||||
}
|
||||
},
|
||||
[openPreview],
|
||||
);
|
||||
|
||||
const handleMemoContentDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (readonly) return;
|
||||
|
||||
if (instanceMemoRelatedSetting.enableDoubleClickEdit) {
|
||||
e.preventDefault();
|
||||
setShowEditor(true);
|
||||
}
|
||||
},
|
||||
[readonly, instanceMemoRelatedSetting.enableDoubleClickEdit],
|
||||
);
|
||||
|
||||
const handleEditorConfirm = useCallback(() => {
|
||||
setShowEditor(false);
|
||||
userStore.setStatsStateId();
|
||||
}, []);
|
||||
|
||||
const handleEditorCancel = useCallback(() => {
|
||||
setShowEditor(false);
|
||||
}, []);
|
||||
|
||||
// Render inline editor when editing
|
||||
if (showEditor) {
|
||||
return (
|
||||
<MemoEditor
|
||||
autoFocus
|
||||
className="mb-2"
|
||||
cacheKey={`inline-memo-editor-${memoData.name}`}
|
||||
memoName={memoData.name}
|
||||
onConfirm={handleEditorConfirm}
|
||||
onCancel={handleEditorCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render memo card
|
||||
return (
|
||||
<article
|
||||
className={cn(MEMO_CARD_BASE_CLASSES, className)}
|
||||
ref={cardRef}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
onFocus={() => handleShortcutActivation(true)}
|
||||
onBlur={() => handleShortcutActivation(false)}
|
||||
>
|
||||
<MemoHeader
|
||||
memo={memoData}
|
||||
creator={creator}
|
||||
showCreator={props.showCreator}
|
||||
showVisibility={props.showVisibility}
|
||||
showPinned={props.showPinned}
|
||||
isArchived={isArchived}
|
||||
commentAmount={commentAmount}
|
||||
isInMemoDetailPage={isInMemoDetailPage}
|
||||
parentPage={parentPage}
|
||||
readonly={readonly}
|
||||
relativeTimeFormat={relativeTimeFormat}
|
||||
onEdit={() => setShowEditor(true)}
|
||||
onGotoDetail={handleGotoMemoDetailPage}
|
||||
onUnpin={unpinMemo}
|
||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||
nsfw={nsfw}
|
||||
showNSFWContent={showNSFWContent}
|
||||
reactionSelectorOpen={reactionSelectorOpen}
|
||||
onReactionSelectorOpenChange={setReactionSelectorOpen}
|
||||
/>
|
||||
|
||||
<MemoBody
|
||||
memo={memoData}
|
||||
readonly={readonly}
|
||||
compact={props.compact}
|
||||
parentPage={parentPage}
|
||||
nsfw={nsfw}
|
||||
showNSFWContent={showNSFWContent}
|
||||
onContentClick={handleMemoContentClick}
|
||||
onContentDoubleClick={handleMemoContentDoubleClick}
|
||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||
/>
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewState.open}
|
||||
onOpenChange={setPreviewOpen}
|
||||
imgUrls={previewState.urls}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
export default memo(MemoView);
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemoContent from "../../MemoContent";
|
||||
import { MemoReactionListView } from "../../MemoReactionListView";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "../../memo-metadata";
|
||||
import type { MemoBodyProps } from "../types";
|
||||
|
||||
/**
|
||||
* MemoBody component displays the main content of a memo including:
|
||||
* - Memo content (markdown)
|
||||
* - Location display
|
||||
* - Attachments
|
||||
* - Related memos
|
||||
* - Reactions
|
||||
* - NSFW content overlay
|
||||
*/
|
||||
const MemoBody: React.FC<MemoBodyProps> = ({
|
||||
memo,
|
||||
readonly,
|
||||
compact,
|
||||
parentPage,
|
||||
nsfw,
|
||||
showNSFWContent,
|
||||
onContentClick,
|
||||
onContentDoubleClick,
|
||||
onToggleNsfwVisibility,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex flex-col justify-start items-start gap-2",
|
||||
nsfw && !showNSFWContent && "blur-lg transition-all duration-200",
|
||||
)}
|
||||
>
|
||||
<MemoContent
|
||||
key={`${memo.name}-${memo.updateTime}`}
|
||||
memoName={memo.name}
|
||||
content={memo.content}
|
||||
readonly={readonly}
|
||||
onClick={onContentClick}
|
||||
onDoubleClick={onContentDoubleClick}
|
||||
compact={memo.pinned ? false : compact} // Always show full content when pinned
|
||||
parentPage={parentPage}
|
||||
/>
|
||||
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
|
||||
<AttachmentList mode="view" attachments={memo.attachments} />
|
||||
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
||||
<MemoReactionListView memo={memo} reactions={memo.reactions} />
|
||||
</div>
|
||||
|
||||
{/* NSFW content overlay */}
|
||||
{nsfw && !showNSFWContent && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-transparent" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 py-2 px-4 text-sm text-muted-foreground hover:text-foreground hover:bg-accent hover:border-accent border border-border rounded-lg bg-card transition-colors"
|
||||
onClick={onToggleNsfwVisibility}
|
||||
>
|
||||
{t("memo.click-to-show-nsfw-content")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoBody;
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityToString } from "@/utils/memo";
|
||||
import MemoActionMenu from "../../MemoActionMenu";
|
||||
import { ReactionSelector } from "../../reactions";
|
||||
import UserAvatar from "../../UserAvatar";
|
||||
import VisibilityIcon from "../../VisibilityIcon";
|
||||
import type { MemoHeaderProps } from "../types";
|
||||
|
||||
/**
|
||||
* MemoHeader component displays the top section of a memo card including:
|
||||
* - Creator info (avatar, name) when showCreator is true
|
||||
* - Display time (relative or absolute)
|
||||
* - Reaction selector
|
||||
* - Comment count link
|
||||
* - Visibility icon
|
||||
* - Pin indicator
|
||||
* - NSFW hide button
|
||||
* - Action menu
|
||||
*/
|
||||
const MemoHeader: React.FC<MemoHeaderProps> = ({
|
||||
memo,
|
||||
creator,
|
||||
showCreator,
|
||||
showVisibility,
|
||||
showPinned,
|
||||
isArchived,
|
||||
commentAmount,
|
||||
isInMemoDetailPage,
|
||||
parentPage,
|
||||
readonly,
|
||||
relativeTimeFormat,
|
||||
onEdit,
|
||||
onGotoDetail,
|
||||
onUnpin,
|
||||
onToggleNsfwVisibility,
|
||||
nsfw,
|
||||
showNSFWContent,
|
||||
reactionSelectorOpen,
|
||||
onReactionSelectorOpenChange,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
|
||||
const displayTime = isArchived ? (
|
||||
memo.displayTime?.toLocaleString(i18n.language)
|
||||
) : (
|
||||
<relative-time datetime={memo.displayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat}></relative-time>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-between items-center gap-2">
|
||||
{/* Left section: Creator info or time */}
|
||||
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
||||
{showCreator && creator ? (
|
||||
<CreatorDisplay creator={creator} displayTime={displayTime} onGotoDetail={onGotoDetail} />
|
||||
) : (
|
||||
<TimeDisplay displayTime={displayTime} onGotoDetail={onGotoDetail} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right section: Actions */}
|
||||
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
||||
{/* Reaction selector */}
|
||||
{!isArchived && (
|
||||
<ReactionSelector
|
||||
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "hidden group-hover:block")}
|
||||
memo={memo}
|
||||
onOpenChange={onReactionSelectorOpenChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comment count link */}
|
||||
{!isInMemoDetailPage && (
|
||||
<Link
|
||||
className={cn(
|
||||
"flex flex-row justify-start items-center rounded-md p-1 hover:opacity-80",
|
||||
commentAmount === 0 && "invisible group-hover:visible",
|
||||
)}
|
||||
to={`/${memo.name}#comments`}
|
||||
viewTransition
|
||||
state={{ from: parentPage }}
|
||||
>
|
||||
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-muted-foreground" />
|
||||
{commentAmount > 0 && <span className="text-xs text-muted-foreground">{commentAmount}</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Visibility icon */}
|
||||
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="flex justify-center items-center rounded-md hover:opacity-80">
|
||||
<VisibilityIcon visibility={memo.visibility} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as Parameters<typeof t>[0])}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Pinned indicator */}
|
||||
{showPinned && memo.pinned && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-pointer">
|
||||
<BookmarkIcon className="w-4 h-auto text-primary" onClick={onUnpin} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.unpin")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* NSFW hide button */}
|
||||
{nsfw && showNSFWContent && onToggleNsfwVisibility && (
|
||||
<span className="cursor-pointer">
|
||||
<EyeOffIcon className="w-4 h-auto text-primary" onClick={onToggleNsfwVisibility} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action menu */}
|
||||
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creator display with avatar and name
|
||||
*/
|
||||
interface CreatorDisplayProps {
|
||||
creator: NonNullable<MemoHeaderProps["creator"]>;
|
||||
displayTime: React.ReactNode;
|
||||
onGotoDetail: () => void;
|
||||
}
|
||||
|
||||
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, onGotoDetail }) => (
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<Link className="w-auto hover:opacity-80 rounded-md transition-colors" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
|
||||
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
|
||||
</Link>
|
||||
<div className="w-full flex flex-col justify-center items-start">
|
||||
<Link
|
||||
className="block leading-tight hover:opacity-80 rounded-md transition-colors truncate text-muted-foreground"
|
||||
to={`/u/${encodeURIComponent(creator.username)}`}
|
||||
viewTransition
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
|
||||
onClick={onGotoDetail}
|
||||
>
|
||||
{displayTime}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Simple time display without creator info
|
||||
*/
|
||||
interface TimeDisplayProps {
|
||||
displayTime: React.ReactNode;
|
||||
onGotoDetail: () => void;
|
||||
}
|
||||
|
||||
const TimeDisplay: React.FC<TimeDisplayProps> = ({ displayTime, onGotoDetail }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
|
||||
onClick={onGotoDetail}
|
||||
>
|
||||
{displayTime}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default MemoHeader;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as MemoBody } from "./MemoBody";
|
||||
export { default as MemoHeader } from "./MemoHeader";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { useImagePreview, useKeyboardShortcuts, useMemoActions, useMemoCreator, useNsfwContent } from "./useMemoViewState";
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { KEYBOARD_SHORTCUTS, TEXT_INPUT_TYPES } from "../constants";
|
||||
import type {
|
||||
ImagePreviewState,
|
||||
UseImagePreviewReturn,
|
||||
UseKeyboardShortcutsOptions,
|
||||
UseMemoActionsReturn,
|
||||
UseNsfwContentReturn,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Hook for handling memo actions (archive, unpin)
|
||||
*/
|
||||
export const useMemoActions = (memo: Memo): UseMemoActionsReturn => {
|
||||
const t = useTranslate();
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
|
||||
const archiveMemo = useCallback(async () => {
|
||||
if (isArchived) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
state: State.ARCHIVED,
|
||||
},
|
||||
["state"],
|
||||
);
|
||||
toast.success(t("message.archived-successfully"));
|
||||
userStore.setStatsStateId();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const err = error as { details?: string };
|
||||
toast.error(err?.details || "Failed to archive memo");
|
||||
}
|
||||
}, [isArchived, memo.name, t]);
|
||||
|
||||
const unpinMemo = useCallback(async () => {
|
||||
if (!memo.pinned) {
|
||||
return;
|
||||
}
|
||||
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
name: memo.name,
|
||||
pinned: false,
|
||||
},
|
||||
["pinned"],
|
||||
);
|
||||
}, [memo.name, memo.pinned]);
|
||||
|
||||
return { archiveMemo, unpinMemo };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for handling keyboard shortcuts on the memo card
|
||||
*/
|
||||
export const useKeyboardShortcuts = (
|
||||
cardRef: React.RefObject<HTMLDivElement | null>,
|
||||
options: UseKeyboardShortcutsOptions,
|
||||
): {
|
||||
shortcutActive: boolean;
|
||||
handleShortcutActivation: (active: boolean) => void;
|
||||
} => {
|
||||
const { enabled, readonly, showEditor, isArchived, onEdit, onArchive } = options;
|
||||
const [shortcutActive, setShortcutActive] = useState(false);
|
||||
|
||||
const isTextInputElement = useCallback((element: HTMLElement | null): boolean => {
|
||||
if (!element) return false;
|
||||
if (element.isContentEditable) return true;
|
||||
if (element instanceof HTMLTextAreaElement) return true;
|
||||
|
||||
if (element instanceof HTMLInputElement) {
|
||||
return TEXT_INPUT_TYPES.includes(element.type as (typeof TEXT_INPUT_TYPES)[number]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || readonly || showEditor || !cardRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cardEl = cardRef.current;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!cardEl.contains(target) || isTextInputElement(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === KEYBOARD_SHORTCUTS.EDIT) {
|
||||
event.preventDefault();
|
||||
onEdit();
|
||||
} else if (key === KEYBOARD_SHORTCUTS.ARCHIVE && !isArchived) {
|
||||
event.preventDefault();
|
||||
onArchive();
|
||||
}
|
||||
};
|
||||
|
||||
cardEl.addEventListener("keydown", handleKeyDown);
|
||||
return () => cardEl.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, readonly, showEditor, isArchived, onEdit, onArchive, cardRef, isTextInputElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showEditor || readonly) {
|
||||
setShortcutActive(false);
|
||||
}
|
||||
}, [showEditor, readonly]);
|
||||
|
||||
const handleShortcutActivation = useCallback(
|
||||
(active: boolean) => {
|
||||
if (readonly) return;
|
||||
setShortcutActive(active);
|
||||
},
|
||||
[readonly],
|
||||
);
|
||||
|
||||
return { shortcutActive, handleShortcutActivation };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing NSFW content visibility
|
||||
*/
|
||||
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
const nsfw =
|
||||
instanceMemoRelatedSetting.enableBlurNsfwContent &&
|
||||
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
|
||||
|
||||
const toggleNsfwVisibility = useCallback(() => {
|
||||
setShowNSFWContent((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
nsfw: nsfw ?? false,
|
||||
showNSFWContent,
|
||||
toggleNsfwVisibility,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing image preview dialog state
|
||||
*/
|
||||
export const useImagePreview = (): UseImagePreviewReturn => {
|
||||
const [previewState, setPreviewState] = useState<ImagePreviewState>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const openPreview = useCallback((url: string) => {
|
||||
setPreviewState({ open: true, urls: [url], index: 0 });
|
||||
}, []);
|
||||
|
||||
const closePreview = useCallback(() => {
|
||||
setPreviewState((prev) => ({ ...prev, open: false }));
|
||||
}, []);
|
||||
|
||||
const setPreviewOpen = useCallback((open: boolean) => {
|
||||
setPreviewState((prev) => ({ ...prev, open }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
previewState,
|
||||
openPreview,
|
||||
closePreview,
|
||||
setPreviewOpen,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing memo creator data
|
||||
*/
|
||||
export const useMemoCreator = (creatorName: string) => {
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
|
||||
(async () => {
|
||||
const user = await userStore.getOrFetchUserByName(creatorName);
|
||||
setCreator(user);
|
||||
})();
|
||||
}, [creatorName]);
|
||||
|
||||
return creator;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import type { User } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
/**
|
||||
* Props for the MemoView component
|
||||
*/
|
||||
export interface MemoViewProps {
|
||||
/** The memo data to display */
|
||||
memo: Memo;
|
||||
/** Enable compact mode with truncated content */
|
||||
compact?: boolean;
|
||||
/** Show creator avatar and name */
|
||||
showCreator?: boolean;
|
||||
/** Show visibility icon */
|
||||
showVisibility?: boolean;
|
||||
/** Show pinned indicator */
|
||||
showPinned?: boolean;
|
||||
/** Show NSFW content without blur */
|
||||
showNsfwContent?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Parent page path for navigation state */
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MemoHeader component
|
||||
*/
|
||||
export interface MemoHeaderProps {
|
||||
memo: Memo;
|
||||
creator: User | undefined;
|
||||
showCreator?: boolean;
|
||||
showVisibility?: boolean;
|
||||
showPinned?: boolean;
|
||||
isArchived: boolean;
|
||||
commentAmount: number;
|
||||
isInMemoDetailPage: boolean;
|
||||
parentPage: string;
|
||||
readonly: boolean;
|
||||
relativeTimeFormat: "datetime" | "auto";
|
||||
onEdit: () => void;
|
||||
onGotoDetail: () => void;
|
||||
onUnpin: () => void;
|
||||
onToggleNsfwVisibility?: () => void;
|
||||
nsfw?: boolean;
|
||||
showNSFWContent?: boolean;
|
||||
reactionSelectorOpen: boolean;
|
||||
onReactionSelectorOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the MemoBody component
|
||||
*/
|
||||
export interface MemoBodyProps {
|
||||
memo: Memo;
|
||||
readonly: boolean;
|
||||
compact?: boolean;
|
||||
parentPage: string;
|
||||
nsfw: boolean;
|
||||
showNSFWContent: boolean;
|
||||
onContentClick: (e: React.MouseEvent) => void;
|
||||
onContentDoubleClick: (e: React.MouseEvent) => void;
|
||||
onToggleNsfwVisibility: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for image preview dialog
|
||||
*/
|
||||
export interface ImagePreviewState {
|
||||
open: boolean;
|
||||
urls: string[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useMemoActions hook
|
||||
*/
|
||||
export interface UseMemoActionsReturn {
|
||||
archiveMemo: () => Promise<void>;
|
||||
unpinMemo: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useKeyboardShortcuts hook
|
||||
*/
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
enabled: boolean;
|
||||
readonly: boolean;
|
||||
showEditor: boolean;
|
||||
isArchived: boolean;
|
||||
onEdit: () => void;
|
||||
onArchive: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useNsfwContent hook
|
||||
*/
|
||||
export interface UseNsfwContentReturn {
|
||||
nsfw: boolean;
|
||||
showNSFWContent: boolean;
|
||||
toggleNsfwVisibility: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type for useImagePreview hook
|
||||
*/
|
||||
export interface UseImagePreviewReturn {
|
||||
previewState: ImagePreviewState;
|
||||
openPreview: (url: string) => void;
|
||||
closePreview: () => void;
|
||||
setPreviewOpen: (open: boolean) => void;
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { SmilePlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRef, useState } from "react";
|
||||
import useClickAway from "react-use/lib/useClickAway";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore, memoStore } from "@/store";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
className?: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ReactionSelector = observer((props: Props) => {
|
||||
const { memo, className, onOpenChange } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
useClickAway(containerRef, () => {
|
||||
setOpen(false);
|
||||
onOpenChange?.(false);
|
||||
});
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
};
|
||||
|
||||
const hasReacted = (reactionType: string) => {
|
||||
return memo.reactions.some((r) => r.reactionType === reactionType && r.creator === currentUser?.name);
|
||||
};
|
||||
|
||||
const handleReactionClick = async (reactionType: string) => {
|
||||
try {
|
||||
if (hasReacted(reactionType)) {
|
||||
const reactions = memo.reactions.filter(
|
||||
(reaction) => reaction.reactionType === reactionType && reaction.creator === currentUser.name,
|
||||
);
|
||||
for (const reaction of reactions) {
|
||||
await memoServiceClient.deleteMemoReaction({ name: reaction.name });
|
||||
}
|
||||
} else {
|
||||
await memoServiceClient.upsertMemoReaction({
|
||||
name: memo.name,
|
||||
reaction: {
|
||||
contentId: memo.name,
|
||||
reactionType: reactionType,
|
||||
},
|
||||
});
|
||||
}
|
||||
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
|
||||
} catch {
|
||||
// skip error.
|
||||
}
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"h-7 w-7 flex justify-center items-center rounded-full border cursor-pointer transition-all hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SmilePlusIcon className="w-4 h-4 mx-auto text-muted-foreground" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
|
||||
<div ref={containerRef}>
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">
|
||||
{instanceMemoRelatedSetting.reactions.map((reactionType) => {
|
||||
return (
|
||||
<span
|
||||
key={reactionType}
|
||||
className={cn(
|
||||
"inline-flex w-auto text-base cursor-pointer rounded px-1 text-muted-foreground hover:opacity-80 transition-colors",
|
||||
hasReacted(reactionType) && "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
onClick={() => handleReactionClick(reactionType)}
|
||||
>
|
||||
{reactionType}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default ReactionSelector;
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { memoServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||
import { User } from "@/types/proto/api/v1/user_service";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
reactionType: string;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
const stringifyUsers = (users: User[], reactionType: string): string => {
|
||||
if (users.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (users.length < 5) {
|
||||
return users.map((user) => user.displayName || user.username).join(", ") + " reacted with " + reactionType.toLowerCase();
|
||||
}
|
||||
return (
|
||||
`${users
|
||||
.slice(0, 4)
|
||||
.map((user) => user.displayName || user.username)
|
||||
.join(", ")} and ${users.length - 4} more reacted with ` + reactionType.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
const ReactionView = observer((props: Props) => {
|
||||
const { memo, reactionType, users } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
||||
const readonly = memo.state === State.ARCHIVED;
|
||||
|
||||
const handleReactionClick = async () => {
|
||||
if (!currentUser || readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = users.findIndex((user) => user.username === currentUser.username);
|
||||
try {
|
||||
if (index === -1) {
|
||||
await memoServiceClient.upsertMemoReaction({
|
||||
name: memo.name,
|
||||
reaction: {
|
||||
contentId: memo.name,
|
||||
reactionType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const reactions = memo.reactions.filter(
|
||||
(reaction) => reaction.reactionType === reactionType && reaction.creator === currentUser.name,
|
||||
);
|
||||
for (const reaction of reactions) {
|
||||
await memoServiceClient.deleteMemoReaction({ name: reaction.name });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip error.
|
||||
}
|
||||
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"h-7 border px-2 py-0.5 rounded-full flex flex-row justify-center items-center gap-1",
|
||||
"text-sm text-muted-foreground",
|
||||
currentUser && !readonly && "cursor-pointer",
|
||||
hasReaction && "bg-accent border-border",
|
||||
)}
|
||||
onClick={handleReactionClick}
|
||||
>
|
||||
<span>{reactionType}</span>
|
||||
<span className="opacity-60">{users.length}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{stringifyUsers(users, reactionType)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export default ReactionView;
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { SmilePlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore } from "@/store";
|
||||
import { useReactionActions } from "./hooks";
|
||||
import type { ReactionSelectorProps } from "./types";
|
||||
|
||||
/**
|
||||
* ReactionSelector component provides a popover for selecting emoji reactions
|
||||
*/
|
||||
const ReactionSelector = observer((props: ReactionSelectorProps) => {
|
||||
const { memo, className, onOpenChange } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
const { hasReacted, handleReactionClick } = useReactionActions({
|
||||
memo,
|
||||
onComplete: () => handleOpenChange(false),
|
||||
});
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"h-7 w-7 flex justify-center items-center rounded-full border cursor-pointer transition-all hover:opacity-80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SmilePlusIcon className="w-4 h-4 mx-auto text-muted-foreground" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">
|
||||
{instanceMemoRelatedSetting.reactions.map((reactionType) => (
|
||||
<button
|
||||
type="button"
|
||||
key={reactionType}
|
||||
className={cn(
|
||||
"inline-flex w-auto text-base cursor-pointer rounded px-1 text-muted-foreground hover:opacity-80 transition-colors",
|
||||
hasReacted(reactionType) && "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
onClick={() => handleReactionClick(reactionType)}
|
||||
>
|
||||
{reactionType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export default ReactionSelector;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common";
|
||||
import { formatReactionTooltip, useReactionActions } from "./hooks";
|
||||
import type { ReactionViewProps } from "./types";
|
||||
|
||||
/**
|
||||
* ReactionView component displays a single reaction pill with count
|
||||
* Clicking toggles the reaction for the current user
|
||||
*/
|
||||
const ReactionView = observer((props: ReactionViewProps) => {
|
||||
const { memo, reactionType, users } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
||||
const readonly = memo.state === State.ARCHIVED;
|
||||
|
||||
const { handleReactionClick } = useReactionActions({ memo });
|
||||
|
||||
const handleClick = () => {
|
||||
if (!currentUser || readonly) return;
|
||||
handleReactionClick(reactionType);
|
||||
};
|
||||
|
||||
const isClickable = currentUser && !readonly;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 border px-2 py-0.5 rounded-full flex flex-row justify-center items-center gap-1",
|
||||
"text-sm text-muted-foreground",
|
||||
isClickable && "cursor-pointer",
|
||||
!isClickable && "cursor-default",
|
||||
hasReaction && "bg-accent border-border",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
disabled={!isClickable}
|
||||
>
|
||||
<span>{reactionType}</span>
|
||||
<span className="opacity-60">{users.length}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{formatReactionTooltip(users, reactionType)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export default ReactionView;
|
||||
|
|
@ -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()}`;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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[];
|
||||
}
|
||||
Loading…
Reference in New Issue