mirror of https://github.com/usememos/memos.git
chore: reorganize reaction components
This commit is contained in:
parent
6dcf7cc74c
commit
07072b75a7
|
|
@ -2,17 +2,17 @@ import { observer } from "mobx-react-lite";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { State } from "@/types/proto/api/v1/common";
|
import { State } from "@/types/proto/api/v1/common";
|
||||||
import { ReactionSelector, ReactionView } from "../reactions";
|
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service";
|
||||||
import { useReactionGroups } from "./hooks";
|
import { useReactionGroups } from "./hooks";
|
||||||
import type { MemoReactionListViewProps } from "./types";
|
import ReactionSelector from "./ReactionSelector";
|
||||||
|
import ReactionView from "./ReactionView";
|
||||||
|
|
||||||
/**
|
interface Props {
|
||||||
* MemoReactionListView displays the reactions on a memo:
|
memo: Memo;
|
||||||
* - Groups reactions by type
|
reactions: Reaction[];
|
||||||
* - Shows reaction emoji with count
|
}
|
||||||
* - Allows adding new reactions (if not readonly)
|
|
||||||
*/
|
const MemoReactionListView = observer((props: Props) => {
|
||||||
const MemoReactionListView = observer((props: MemoReactionListViewProps) => {
|
|
||||||
const { memo: memoData, reactions } = props;
|
const { memo: memoData, reactions } = props;
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const reactionGroup = useReactionGroups(reactions);
|
const reactionGroup = useReactionGroups(reactions);
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
import { SmilePlusIcon } from "lucide-react";
|
import { SmilePlusIcon } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useCallback, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { instanceStore } from "@/store";
|
import { instanceStore } from "@/store";
|
||||||
|
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
import { useReactionActions } from "./hooks";
|
import { useReactionActions } from "./hooks";
|
||||||
import type { ReactionSelectorProps } from "./types";
|
|
||||||
|
|
||||||
/**
|
interface Props {
|
||||||
* ReactionSelector component provides a popover for selecting emoji reactions
|
memo: Memo;
|
||||||
*/
|
className?: string;
|
||||||
const ReactionSelector = observer((props: ReactionSelectorProps) => {
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReactionSelector = observer((props: Props) => {
|
||||||
const { memo, className, onOpenChange } = props;
|
const { memo, className, onOpenChange } = props;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
(newOpen: boolean) => {
|
setOpen(newOpen);
|
||||||
setOpen(newOpen);
|
onOpenChange?.(newOpen);
|
||||||
onOpenChange?.(newOpen);
|
};
|
||||||
},
|
|
||||||
[onOpenChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { hasReacted, handleReactionClick } = useReactionActions({
|
const { hasReacted, handleReactionClick } = useReactionActions({
|
||||||
memo,
|
memo,
|
||||||
|
|
@ -3,14 +3,17 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { State } from "@/types/proto/api/v1/common";
|
import { State } from "@/types/proto/api/v1/common";
|
||||||
|
import type { Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
|
import type { User } from "@/types/proto/api/v1/user_service";
|
||||||
import { formatReactionTooltip, useReactionActions } from "./hooks";
|
import { formatReactionTooltip, useReactionActions } from "./hooks";
|
||||||
import type { ReactionViewProps } from "./types";
|
|
||||||
|
|
||||||
/**
|
interface Props {
|
||||||
* ReactionView component displays a single reaction pill with count
|
memo: Memo;
|
||||||
* Clicking toggles the reaction for the current user
|
reactionType: string;
|
||||||
*/
|
users: User[];
|
||||||
const ReactionView = observer((props: ReactionViewProps) => {
|
}
|
||||||
|
|
||||||
|
const ReactionView = observer((props: Props) => {
|
||||||
const { memo, reactionType, users } = props;
|
const { memo, reactionType, users } = props;
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
||||||
|
|
@ -1,32 +1,77 @@
|
||||||
import { uniq } from "lodash-es";
|
import { uniq } from "lodash-es";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { userStore } from "@/store";
|
import { memoServiceClient } from "@/grpcweb";
|
||||||
import type { Reaction } from "@/types/proto/api/v1/memo_service";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import { memoStore, userStore } from "@/store";
|
||||||
|
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service";
|
||||||
import type { User } from "@/types/proto/api/v1/user_service";
|
import type { User } from "@/types/proto/api/v1/user_service";
|
||||||
import type { ReactionGroup } from "./types";
|
|
||||||
|
|
||||||
/**
|
export type ReactionGroup = Map<string, User[]>;
|
||||||
* Hook for grouping reactions by type and fetching user data
|
|
||||||
*/
|
|
||||||
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
|
export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
|
||||||
const [reactionGroup, setReactionGroup] = useState<ReactionGroup>(new Map());
|
const [reactionGroup, setReactionGroup] = useState<ReactionGroup>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchReactionGroups = async () => {
|
const fetchReactionGroups = async () => {
|
||||||
const newReactionGroup = new Map<string, User[]>();
|
const newReactionGroup = new Map<string, User[]>();
|
||||||
|
|
||||||
for (const reaction of reactions) {
|
for (const reaction of reactions) {
|
||||||
const user = await userStore.getOrFetchUserByName(reaction.creator);
|
const user = await userStore.getOrFetchUserByName(reaction.creator);
|
||||||
const users = newReactionGroup.get(reaction.reactionType) || [];
|
const users = newReactionGroup.get(reaction.reactionType) || [];
|
||||||
users.push(user);
|
users.push(user);
|
||||||
newReactionGroup.set(reaction.reactionType, uniq(users));
|
newReactionGroup.set(reaction.reactionType, uniq(users));
|
||||||
}
|
}
|
||||||
|
|
||||||
setReactionGroup(newReactionGroup);
|
setReactionGroup(newReactionGroup);
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchReactionGroups();
|
fetchReactionGroups();
|
||||||
}, [reactions]);
|
}, [reactions]);
|
||||||
|
|
||||||
return reactionGroup;
|
return reactionGroup;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface UseReactionActionsOptions {
|
||||||
|
memo: Memo;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptions) => {
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
|
const hasReacted = (reactionType: string) => {
|
||||||
|
return memo.reactions.some((r) => r.reactionType === reactionType && r.creator === currentUser?.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReactionClick = 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?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { hasReacted, handleReactionClick };
|
||||||
|
};
|
||||||
|
|
||||||
|
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()}`;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { useReactionGroups } from "./hooks";
|
|
||||||
export { default, default as MemoReactionListView } from "./MemoReactionListView";
|
export { default, default as MemoReactionListView } from "./MemoReactionListView";
|
||||||
export type { MemoReactionListViewProps, ReactionGroup } from "./types";
|
export { default as ReactionSelector } from "./ReactionSelector";
|
||||||
|
export { default as ReactionView } from "./ReactionView";
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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[]>;
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { User } from "@/types/proto/api/v1/user_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityToString } from "@/utils/memo";
|
import { convertVisibilityToString } from "@/utils/memo";
|
||||||
import MemoActionMenu from "../../MemoActionMenu";
|
import MemoActionMenu from "../../MemoActionMenu";
|
||||||
import { ReactionSelector } from "../../reactions";
|
import { ReactionSelector } from "../../MemoReactionListView";
|
||||||
import UserAvatar from "../../UserAvatar";
|
import UserAvatar from "../../UserAvatar";
|
||||||
import VisibilityIcon from "../../VisibilityIcon";
|
import VisibilityIcon from "../../VisibilityIcon";
|
||||||
import { useMemoViewContext } from "../MemoViewContext";
|
import { useMemoViewContext } from "../MemoViewContext";
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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()}`;
|
|
||||||
};
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
/**
|
|
||||||
* 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";
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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