chore: reorganize reaction components

This commit is contained in:
Johnny 2025-11-30 12:48:21 +08:00
parent 6dcf7cc74c
commit 07072b75a7
10 changed files with 88 additions and 172 deletions

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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()}`;
};

View File

@ -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";

View File

@ -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[]>;

View File

@ -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";

View File

@ -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()}`;
};

View File

@ -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";

View File

@ -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[];
}