diff --git a/web/src/components/MemoReactionistView.tsx b/web/src/components/MemoReactionistView.tsx new file mode 100644 index 00000000..a2a0c339 --- /dev/null +++ b/web/src/components/MemoReactionistView.tsx @@ -0,0 +1,43 @@ +import { uniq } from "lodash-es"; +import { memo, useEffect, useState } from "react"; +import { extractUsernameFromName, useUserStore } from "@/store/v1"; +import { Memo } from "@/types/proto/api/v2/memo_service"; +import { Reaction, Reaction_Type } from "@/types/proto/api/v2/reaction_service"; +import { User } from "@/types/proto/api/v2/user_service"; +import ReactionSelector from "./ReactionSelector"; +import ReactionView from "./ReactionView"; + +interface Props { + memo: Memo; + reactions: Reaction[]; +} + +const MemoReactionListView = (props: Props) => { + const { memo, reactions } = props; + const userStore = useUserStore(); + const [reactionGroup, setReactionGroup] = useState>(new Map()); + + useEffect(() => { + (async () => { + const reactionGroup = new Map(); + for (const reaction of reactions) { + const user = await userStore.getOrFetchUserByUsername(extractUsernameFromName(reaction.creator)); + const users = reactionGroup.get(reaction.reactionType) || []; + users.push(user); + reactionGroup.set(reaction.reactionType, uniq(users)); + } + setReactionGroup(reactionGroup); + })(); + }, [reactions]); + + return ( +
+ + {Array.from(reactionGroup).map(([reactionType, users]) => { + return ; + })} +
+ ); +}; + +export default memo(MemoReactionListView); diff --git a/web/src/components/MemoRelationListView.tsx b/web/src/components/MemoRelationListView.tsx index e53d11d3..22f96227 100644 --- a/web/src/components/MemoRelationListView.tsx +++ b/web/src/components/MemoRelationListView.tsx @@ -8,11 +8,11 @@ import Icon from "./Icon"; interface Props { memo: Memo; - relationList: MemoRelation[]; + relations: MemoRelation[]; } const MemoRelationListView = (props: Props) => { - const { memo, relationList } = props; + const { memo, relations: relationList } = props; const memoStore = useMemoStore(); const [referencingMemoList, setReferencingMemoList] = useState([]); const [referencedMemoList, setReferencedMemoList] = useState([]); diff --git a/web/src/components/MemoView.tsx b/web/src/components/MemoView.tsx index 98c0e690..a666a53c 100644 --- a/web/src/components/MemoView.tsx +++ b/web/src/components/MemoView.tsx @@ -20,6 +20,7 @@ import { showCommonDialog } from "./Dialog/CommonDialog"; import Icon from "./Icon"; import MemoContent from "./MemoContent"; import showMemoEditorDialog from "./MemoEditor/MemoEditorDialog"; +import MemoReactionistView from "./MemoReactionistView"; import MemoRelationListView from "./MemoRelationListView"; import MemoResourceListView from "./MemoResourceListView"; import showPreviewImageDialog from "./PreviewImageDialog"; @@ -265,7 +266,8 @@ const MemoView: React.FC = (props: Props) => { onClick={handleMemoContentClick} /> - + + ); }; diff --git a/web/src/components/ReactionSelector.tsx b/web/src/components/ReactionSelector.tsx new file mode 100644 index 00000000..e9624db6 --- /dev/null +++ b/web/src/components/ReactionSelector.tsx @@ -0,0 +1,79 @@ +import { Dropdown, Menu, MenuButton } from "@mui/joy"; +import { useRef, useState } from "react"; +import useClickAway from "react-use/lib/useClickAway"; +import Icon from "@/components/Icon"; +import { memoServiceClient } from "@/grpcweb"; +import { MemoNamePrefix, useMemoStore } from "@/store/v1"; +import { Memo } from "@/types/proto/api/v2/memo_service"; +import { Reaction_Type } from "@/types/proto/api/v2/reaction_service"; +import { stringifyReactionType } from "./ReactionView"; + +interface Props { + memo: Memo; +} + +const REACTION_TYPES = [ + Reaction_Type.THUMBS_UP, + Reaction_Type.HEART, + Reaction_Type.ROCKET, + Reaction_Type.LAUGH, + Reaction_Type.EYES, + Reaction_Type.THUMBS_DOWN, +]; + +const ReactionSelector = (props: Props) => { + const { memo } = props; + const memoStore = useMemoStore(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useClickAway(containerRef, () => { + setOpen(false); + }); + + const handleReactionClick = async (reaction: Reaction_Type) => { + try { + await memoServiceClient.upsertMemoReaction({ + id: memo.id, + reaction: { + contentId: `${MemoNamePrefix}${memo.id}`, + reactionType: reaction, + }, + }); + await memoStore.getOrFetchMemoById(memo.id, { + skipCache: true, + }); + } catch (error) { + // skip error. + } + }; + + return ( + setOpen(isOpen)}> + + + + + + +
+
+ {REACTION_TYPES.map((reactionType) => { + return ( +
handleReactionClick(reactionType)} + > + {stringifyReactionType(reactionType)} +
+ ); + })} +
+
+
+
+ ); +}; + +export default ReactionSelector; diff --git a/web/src/components/ReactionView.tsx b/web/src/components/ReactionView.tsx new file mode 100644 index 00000000..61d75606 --- /dev/null +++ b/web/src/components/ReactionView.tsx @@ -0,0 +1,100 @@ +import { Tooltip } from "@mui/joy"; +import classNames from "classnames"; +import { memoServiceClient } from "@/grpcweb"; +import useCurrentUser from "@/hooks/useCurrentUser"; +import { MemoNamePrefix, useMemoStore } from "@/store/v1"; +import { Memo } from "@/types/proto/api/v2/memo_service"; +import { Reaction_Type } from "@/types/proto/api/v2/reaction_service"; +import { User } from "@/types/proto/api/v2/user_service"; + +interface Props { + memo: Memo; + reactionType: Reaction_Type; + users: User[]; +} + +export const stringifyReactionType = (reactionType: Reaction_Type): string => { + switch (reactionType) { + case Reaction_Type.EYES: + return "👀"; + case Reaction_Type.HEART: + return "💗"; + case Reaction_Type.LAUGH: + return "😂"; + case Reaction_Type.ROCKET: + return "🚀"; + case Reaction_Type.THUMBS_DOWN: + return "👎"; + case Reaction_Type.THUMBS_UP: + return "👍"; + default: + return ""; + } +}; + +const stringifyUsers = (users: User[]): string => { + if (users.length === 0) { + return ""; + } + if (users.length < 5) { + return users.map((user) => user.nickname || user.username).join(", "); + } + return `${users + .slice(0, 5) + .map((user) => user.nickname || user.username) + .join(", ")} and ${users.length - 5} others`; +}; + +const ReactionView = (props: Props) => { + const { memo, reactionType, users } = props; + const currenUser = useCurrentUser(); + const memoStore = useMemoStore(); + const hasReaction = users.some((user) => currenUser && user.username === currenUser.username); + + const handleReactionClick = async () => { + if (!currenUser) { + return; + } + + const index = users.findIndex((user) => user.username === currenUser.username); + try { + if (index === -1) { + await memoServiceClient.upsertMemoReaction({ + id: memo.id, + reaction: { + contentId: `${MemoNamePrefix}${memo.id}`, + reactionType, + }, + }); + } else { + const reactions = memo.reactions.filter( + (reaction) => reaction.reactionType === reactionType && reaction.creator === currenUser.name, + ); + for (const reaction of reactions) { + await memoServiceClient.deleteMemoReaction({ id: reaction.id }); + } + } + } catch (error) { + // Skip error. + } + await memoStore.getOrFetchMemoById(memo.id, { skipCache: true }); + }; + + return ( + +
+ {stringifyReactionType(reactionType)} + {users.length} +
+
+ ); +}; + +export default ReactionView; diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index 591503aa..559425bc 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -139,7 +139,7 @@ const MemoDetail = () => { )} - +
{!readonly && ( diff --git a/web/src/store/v1/resourceName.ts b/web/src/store/v1/resourceName.ts index eb0ab985..80596d0e 100644 --- a/web/src/store/v1/resourceName.ts +++ b/web/src/store/v1/resourceName.ts @@ -1,4 +1,5 @@ export const UserNamePrefix = "users/"; +export const MemoNamePrefix = "memos/"; export const extractUsernameFromName = (name: string = "") => { return name.slice(UserNamePrefix.length);