chore: implement reaction frontend

This commit is contained in:
Steven 2024-02-08 13:25:15 +08:00
parent e5f244cb50
commit d86f0bac8c
7 changed files with 229 additions and 4 deletions

View file

@ -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<Map<Reaction_Type, User[]>>(new Map());
useEffect(() => {
(async () => {
const reactionGroup = new Map<Reaction_Type, User[]>();
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 (
<div className="w-full mt-2 flex flex-row justify-start items-start flex-wrap gap-1">
<ReactionSelector memo={memo} />
{Array.from(reactionGroup).map(([reactionType, users]) => {
return <ReactionView key={`${reactionType.toString()} ${users.length}`} memo={memo} reactionType={reactionType} users={users} />;
})}
</div>
);
};
export default memo(MemoReactionListView);

View file

@ -8,11 +8,11 @@ import Icon from "./Icon";
interface Props { interface Props {
memo: Memo; memo: Memo;
relationList: MemoRelation[]; relations: MemoRelation[];
} }
const MemoRelationListView = (props: Props) => { const MemoRelationListView = (props: Props) => {
const { memo, relationList } = props; const { memo, relations: relationList } = props;
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]); const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]);
const [referencedMemoList, setReferencedMemoList] = useState<Memo[]>([]); const [referencedMemoList, setReferencedMemoList] = useState<Memo[]>([]);

View file

@ -20,6 +20,7 @@ import { showCommonDialog } from "./Dialog/CommonDialog";
import Icon from "./Icon"; import Icon from "./Icon";
import MemoContent from "./MemoContent"; import MemoContent from "./MemoContent";
import showMemoEditorDialog from "./MemoEditor/MemoEditorDialog"; import showMemoEditorDialog from "./MemoEditor/MemoEditorDialog";
import MemoReactionistView from "./MemoReactionistView";
import MemoRelationListView from "./MemoRelationListView"; import MemoRelationListView from "./MemoRelationListView";
import MemoResourceListView from "./MemoResourceListView"; import MemoResourceListView from "./MemoResourceListView";
import showPreviewImageDialog from "./PreviewImageDialog"; import showPreviewImageDialog from "./PreviewImageDialog";
@ -265,7 +266,8 @@ const MemoView: React.FC<Props> = (props: Props) => {
onClick={handleMemoContentClick} onClick={handleMemoContentClick}
/> />
<MemoResourceListView resources={memo.resources} /> <MemoResourceListView resources={memo.resources} />
<MemoRelationListView memo={memo} relationList={referenceRelations} /> <MemoRelationListView memo={memo} relations={referenceRelations} />
<MemoReactionistView memo={memo} reactions={memo.reactions} />
</div> </div>
); );
}; };

View file

@ -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<HTMLDivElement>(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 (
<Dropdown open={open} onOpenChange={(_, isOpen) => setOpen(isOpen)}>
<MenuButton slots={{ root: "div" }} slotProps={{}}>
<span className="h-7 w-7 flex justify-center items-center rounded-full border dark:border-zinc-700 hover:opacity-80">
<Icon.Smile className="w-4 h-4 mx-auto dark:text-gray-400" />
</span>
</MenuButton>
<Menu className="relative text-sm" component="div" size="sm" placement="bottom-start">
<div ref={containerRef}>
<div className="flex-row justify-start items-start py-0.5 px-2 h-auto font-mono space-x-1">
{REACTION_TYPES.map((reactionType) => {
return (
<div
key={reactionType}
className="inline-flex w-auto cursor-pointer rounded text-lg px-1 text-gray-500 dark:text-gray-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
onClick={() => handleReactionClick(reactionType)}
>
{stringifyReactionType(reactionType)}
</div>
);
})}
</div>
</div>
</Menu>
</Dropdown>
);
};
export default ReactionSelector;

View file

@ -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 (
<Tooltip title={stringifyUsers(users)} placement="top">
<div
className={classNames(
"h-7 border px-2 py-0.5 rounded-full font-memo flex flex-row justify-center items-center gap-1 dark:border-zinc-700",
currenUser && "cursor-pointer",
hasReaction && "bg-blue-50 border-blue-100 dark:bg-zinc-900",
)}
onClick={handleReactionClick}
>
<span>{stringifyReactionType(reactionType)}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{users.length}</span>
</div>
</Tooltip>
);
};
export default ReactionView;

View file

@ -139,7 +139,7 @@ const MemoDetail = () => {
)} )}
<MemoContent key={`${memo.id}-${memo.updateTime}`} memoId={memo.id} content={memo.content} readonly={readonly} /> <MemoContent key={`${memo.id}-${memo.updateTime}`} memoId={memo.id} content={memo.content} readonly={readonly} />
<MemoResourceListView resources={memo.resources} /> <MemoResourceListView resources={memo.resources} />
<MemoRelationListView memo={memo} relationList={referenceRelations} /> <MemoRelationListView memo={memo} relations={referenceRelations} />
<div className="w-full mt-3 flex flex-row justify-between items-center gap-2"> <div className="w-full mt-3 flex flex-row justify-between items-center gap-2">
<div className="flex flex-row justify-start items-center"> <div className="flex flex-row justify-start items-center">
{!readonly && ( {!readonly && (

View file

@ -1,4 +1,5 @@
export const UserNamePrefix = "users/"; export const UserNamePrefix = "users/";
export const MemoNamePrefix = "memos/";
export const extractUsernameFromName = (name: string = "") => { export const extractUsernameFromName = (name: string = "") => {
return name.slice(UserNamePrefix.length); return name.slice(UserNamePrefix.length);