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 {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ArchiveRestoreIcon,
|
ArchiveRestoreIcon,
|
||||||
|
|
@ -14,16 +13,8 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import { Button } from "@/components/ui/button";
|
||||||
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -32,134 +23,52 @@ import {
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
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;
|
* MemoActionMenu component provides a dropdown menu with actions for a memo:
|
||||||
readonly?: boolean;
|
* - Pin/Unpin
|
||||||
className?: string;
|
* - Edit
|
||||||
onEdit?: () => void;
|
* - Copy (link/content)
|
||||||
}
|
* - Remove completed tasks
|
||||||
|
* - Archive/Restore
|
||||||
const checkHasCompletedTaskList = (memo: Memo) => {
|
* - Delete
|
||||||
return hasCompletedTasks(memo.content);
|
*/
|
||||||
};
|
const MemoActionMenu = observer((props: MemoActionMenuProps) => {
|
||||||
|
|
||||||
const MemoActionMenu = observer((props: Props) => {
|
|
||||||
const { memo, readonly } = props;
|
const { memo, readonly } = props;
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const location = useLocation();
|
|
||||||
const navigateTo = useNavigateTo();
|
// Dialog state
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [removeTasksDialogOpen, setRemoveTasksDialogOpen] = 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 isComment = Boolean(memo.parent);
|
||||||
const isArchived = memo.state === State.ARCHIVED;
|
const isArchived = memo.state === State.ARCHIVED;
|
||||||
|
|
||||||
const memoUpdatedCallback = () => {
|
// Action handlers
|
||||||
// Refresh user stats.
|
const {
|
||||||
userStore.setStatsStateId();
|
handleTogglePinMemoBtnClick,
|
||||||
};
|
handleEditMemoClick,
|
||||||
|
handleToggleMemoStatusClick,
|
||||||
const handleTogglePinMemoBtnClick = async () => {
|
handleCopyLink,
|
||||||
try {
|
handleCopyContent,
|
||||||
if (memo.pinned) {
|
handleDeleteMemoClick,
|
||||||
await memoStore.updateMemo(
|
confirmDeleteMemo,
|
||||||
{
|
handleRemoveCompletedTaskListItemsClick,
|
||||||
name: memo.name,
|
confirmRemoveCompletedTaskListItems,
|
||||||
pinned: false,
|
} = useMemoActionHandlers({
|
||||||
},
|
memo,
|
||||||
["pinned"],
|
onEdit: props.onEdit,
|
||||||
);
|
setDeleteDialogOpen,
|
||||||
} else {
|
setRemoveTasksDialogOpen,
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -169,6 +78,7 @@ const MemoActionMenu = observer((props: Props) => {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" sideOffset={2}>
|
<DropdownMenuContent align="end" sideOffset={2}>
|
||||||
|
{/* Edit actions (non-readonly, non-archived) */}
|
||||||
{!readonly && !isArchived && (
|
{!readonly && !isArchived && (
|
||||||
<>
|
<>
|
||||||
{!isComment && (
|
{!isComment && (
|
||||||
|
|
@ -183,6 +93,8 @@ const MemoActionMenu = observer((props: Props) => {
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Copy submenu (non-archived) */}
|
||||||
{!isArchived && (
|
{!isArchived && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger>
|
<DropdownMenuSubTrigger>
|
||||||
|
|
@ -201,20 +113,27 @@ const MemoActionMenu = observer((props: Props) => {
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Write actions (non-readonly) */}
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<>
|
<>
|
||||||
|
{/* Remove completed tasks (non-archived, non-comment, has completed tasks) */}
|
||||||
{!isArchived && !isComment && hasCompletedTaskList && (
|
{!isArchived && !isComment && hasCompletedTaskList && (
|
||||||
<DropdownMenuItem onClick={handleRemoveCompletedTaskListItemsClick}>
|
<DropdownMenuItem onClick={handleRemoveCompletedTaskListItemsClick}>
|
||||||
<SquareCheckIcon className="w-4 h-auto" />
|
<SquareCheckIcon className="w-4 h-auto" />
|
||||||
{t("memo.remove-completed-task-list-items")}
|
{t("memo.remove-completed-task-list-items")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Archive/Restore (non-comment) */}
|
||||||
{!isComment && (
|
{!isComment && (
|
||||||
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
|
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
|
||||||
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
|
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
|
||||||
{isArchived ? t("common.restore") : t("common.archive")}
|
{isArchived ? t("common.restore") : t("common.archive")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
<DropdownMenuItem onClick={handleDeleteMemoClick}>
|
<DropdownMenuItem onClick={handleDeleteMemoClick}>
|
||||||
<TrashIcon className="w-4 h-auto" />
|
<TrashIcon className="w-4 h-auto" />
|
||||||
{t("common.delete")}
|
{t("common.delete")}
|
||||||
|
|
@ -222,6 +141,7 @@ const MemoActionMenu = observer((props: Props) => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|
@ -233,6 +153,7 @@ const MemoActionMenu = observer((props: Props) => {
|
||||||
onConfirm={confirmDeleteMemo}
|
onConfirm={confirmDeleteMemo}
|
||||||
confirmVariant="destructive"
|
confirmVariant="destructive"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Remove completed tasks confirmation */}
|
{/* Remove completed tasks confirmation */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={removeTasksDialogOpen}
|
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 { observer } from "mobx-react-lite";
|
||||||
import { memo, useEffect, useRef, useState } from "react";
|
import { memo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw";
|
||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
|
|
@ -13,34 +13,21 @@ import { remarkTag } from "@/utils/remark-plugins/remark-tag";
|
||||||
import { isSuperUser } from "@/utils/user";
|
import { isSuperUser } from "@/utils/user";
|
||||||
import { CodeBlock } from "./CodeBlock";
|
import { CodeBlock } from "./CodeBlock";
|
||||||
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
|
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
|
||||||
|
import { useCompactLabel, useCompactMode } from "./hooks";
|
||||||
import { MemoContentContext } from "./MemoContentContext";
|
import { MemoContentContext } from "./MemoContentContext";
|
||||||
import { Tag } from "./Tag";
|
import { Tag } from "./Tag";
|
||||||
import { TaskListItem } from "./TaskListItem";
|
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 MemoContent = observer((props: MemoContentProps) => {
|
||||||
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 { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
|
const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const memoContentContainerRef = useRef<HTMLDivElement>(null);
|
const {
|
||||||
const [showCompactMode, setShowCompactMode] = useState<ContentCompactView | undefined>(undefined);
|
containerRef: memoContentContainerRef,
|
||||||
|
mode: showCompactMode,
|
||||||
|
toggle: toggleCompactMode,
|
||||||
|
} = useCompactMode(Boolean(props.compact));
|
||||||
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
|
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
|
||||||
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
|
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
|
||||||
|
|
||||||
|
|
@ -53,37 +40,7 @@ const MemoContent = observer((props: Props) => {
|
||||||
containerRef: memoContentContainerRef,
|
containerRef: memoContentContainerRef,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial compact mode.
|
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
|
||||||
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" },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemoContentContext.Provider value={contextValue}>
|
<MemoContentContext.Provider value={contextValue}>
|
||||||
|
|
@ -91,12 +48,12 @@ const MemoContent = observer((props: Props) => {
|
||||||
<div
|
<div
|
||||||
ref={memoContentContainerRef}
|
ref={memoContentContainerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"markdown-content relative w-full max-w-full break-words text-base leading-6",
|
"markdown-content relative w-full max-w-full wrap-break-word text-base leading-6",
|
||||||
showCompactMode == "ALL" && "line-clamp-6 max-h-60",
|
showCompactMode === "ALL" && "line-clamp-6 max-h-60",
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
onClick={onMemoContentClick}
|
onMouseUp={onClick}
|
||||||
onDoubleClick={onMemoContentDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm, remarkBreaks, remarkTag, remarkPreserveType]}
|
remarkPlugins={[remarkGfm, remarkBreaks, remarkTag, remarkPreserveType]}
|
||||||
|
|
@ -116,19 +73,18 @@ const MemoContent = observer((props: Props) => {
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
{showCompactMode == "ALL" && (
|
{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>
|
<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">
|
<div className="w-full mt-1">
|
||||||
<span
|
<button
|
||||||
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80"
|
type="button"
|
||||||
onClick={() => {
|
className="w-auto flex flex-row justify-start items-center cursor-pointer text-sm text-primary hover:opacity-80 text-left"
|
||||||
setShowCompactMode(compactStates[showCompactMode].nextState as ContentCompactView);
|
onClick={toggleCompactMode}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{compactStates[showCompactMode].text}
|
{compactLabel}
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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