mirror of
https://github.com/usememos/memos.git
synced 2024-11-11 01:12:40 +08:00
chore: implement reaction frontend
This commit is contained in:
parent
e5f244cb50
commit
d86f0bac8c
7 changed files with 229 additions and 4 deletions
43
web/src/components/MemoReactionistView.tsx
Normal file
43
web/src/components/MemoReactionistView.tsx
Normal 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);
|
|
@ -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[]>([]);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
79
web/src/components/ReactionSelector.tsx
Normal file
79
web/src/components/ReactionSelector.tsx
Normal 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;
|
100
web/src/components/ReactionView.tsx
Normal file
100
web/src/components/ReactionView.tsx
Normal 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;
|
|
@ -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 && (
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue