Skip to content

Commit

Permalink
UI reactji (#1918)
Browse files Browse the repository at this point in the history
* WIP UI reactji

* Dynamic load emoji data

* use latest nextjs

* feedback rename function

* Add labels to button for tooltip

* Edit emoji button

* remove preview and force theme light
  • Loading branch information
PopDaph authored Oct 3, 2023
1 parent 00f8694 commit 3b08978
Show file tree
Hide file tree
Showing 6 changed files with 341 additions and 103 deletions.
19 changes: 16 additions & 3 deletions front/components/assistant/conversation/AgentMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,24 @@ import {
isRetrievalActionType,
RetrievalDocumentType,
} from "@app/types/assistant/actions/retrieval";
import { AgentMessageType } from "@app/types/assistant/conversation";
import { WorkspaceType } from "@app/types/user";
import {
AgentMessageType,
MessageReactionType,
} from "@app/types/assistant/conversation";
import { UserType, WorkspaceType } from "@app/types/user";

export function AgentMessage({
message,
owner,
user,
conversationId,
reactions,
}: {
message: AgentMessageType;
owner: WorkspaceType;
user: UserType;
conversationId: string;
reactions: MessageReactionType[];
}) {
const [streamedAgentMessage, setStreamedAgentMessage] =
useState<AgentMessageType>(message);
Expand Down Expand Up @@ -163,6 +170,7 @@ export function AgentMessage({
? []
: [
{
label: "Copy to clipboard",
icon: ClipboardIcon,
onClick: () => {
void navigator.clipboard.writeText(
Expand All @@ -171,6 +179,7 @@ export function AgentMessage({
},
},
{
label: "Retry",
icon: ArrowPathIcon,
onClick: () => {
void retryHandler(agentMessageToRender);
Expand All @@ -180,11 +189,15 @@ export function AgentMessage({

return (
<ConversationMessage
owner={owner}
user={user}
conversationId={conversationId}
messageId={agentMessageToRender.sId}
pictureUrl={agentMessageToRender.configuration.pictureUrl}
name={`@${agentMessageToRender.configuration.name}`}
messageId={agentMessageToRender.sId}
buttons={buttons}
avatarBusy={agentMessageToRender.status === "created"}
reactions={reactions}
>
{renderMessage(agentMessageToRender, shouldStream)}
</ConversationMessage>
Expand Down
18 changes: 17 additions & 1 deletion front/components/assistant/conversation/Conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
ConversationTitleEvent,
UserMessageNewEvent,
} from "@app/lib/api/assistant/conversation";
import { useConversation, useConversations } from "@app/lib/swr";
import {
useConversation,
useConversationReactions,
useConversations,
} from "@app/lib/swr";
import {
AgentMention,
AgentMessageType,
Expand Down Expand Up @@ -43,6 +47,11 @@ export default function Conversation({
workspaceId: owner.sId,
});

const { reactions } = useConversationReactions({
workspaceId: owner.sId,
conversationId,
});

useEffect(() => {
if (window && window.scrollTo) {
window.scrollTo(0, document.body.scrollHeight);
Expand Down Expand Up @@ -159,6 +168,9 @@ export default function Conversation({
) => (cur.version > acc.version ? cur : acc)
) as UserMessageType | AgentMessageType;

const convoReactions = reactions.find((r) => r.messageId === m.sId);
const messageReactions = convoReactions?.reactions || [];

if (m.visibility === "deleted") {
return null;
}
Expand All @@ -174,6 +186,8 @@ export default function Conversation({
message={m}
conversation={conversation}
owner={owner}
user={user}
reactions={messageReactions}
/>
</div>
</div>
Expand All @@ -188,7 +202,9 @@ export default function Conversation({
<AgentMessage
message={m}
owner={owner}
user={user}
conversationId={conversationId}
reactions={messageReactions}
/>
</div>
</div>
Expand Down
221 changes: 207 additions & 14 deletions front/components/assistant/conversation/ConversationMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,109 @@
import { Avatar, IconButton } from "@dust-tt/sparkle";
import { ComponentType, MouseEventHandler } from "react";
import {
Avatar,
Button,
DropdownMenu,
IconButton,
PlusIcon,
} from "@dust-tt/sparkle";
import { Emoji, EmojiMartData } from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { ComponentType, MouseEventHandler, useEffect, useState } from "react";
import React from "react";
import { mutate } from "swr";

import { classNames } from "@app/lib/utils";
import { MessageReactionType } from "@app/types/assistant/conversation";
import { UserType, WorkspaceType } from "@app/types/user";

const MAX_REACTIONS_TO_SHOW = 15;

/**
* Parent component for both UserMessage and AgentMessage, to ensure avatar,
* side buttons and spacing are consistent between the two
*/
export function ConversationMessage({
owner,
user,
conversationId,
messageId,
children,
name,
pictureUrl,
buttons,
reactions,
avatarBusy = false,
}: {
owner: WorkspaceType;
user: UserType;
conversationId: string;
messageId: string;
children?: React.ReactNode;
name: string | null;
messageId: string;
pictureUrl?: string | null;
buttons?: {
label: string;
icon: ComponentType;
onClick: MouseEventHandler<HTMLButtonElement>;
}[];
reactions: MessageReactionType[];
avatarBusy?: boolean;
}) {
const [emojiData, setEmojiData] = useState<EmojiMartData | null>(null);

useEffect(() => {
async function loadEmojiData() {
const mod = await import("@emoji-mart/data");
const data: EmojiMartData = mod.default as EmojiMartData;
setEmojiData(data);
}

void loadEmojiData();
}, []);

const handleEmoji = async ({
emoji,
isToRemove,
}: {
emoji: string;
isToRemove: boolean;
}) => {
const res = await fetch(
`/api/w/${owner.sId}/assistant/conversations/${conversationId}/messages/${messageId}/reactions`,
{
method: isToRemove ? "DELETE" : "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
reaction: emoji,
}),
}
);
if (res.ok) {
await mutate(
`/api/w/${owner.sId}/assistant/conversations/${conversationId}/reactions`
);
}
};

const handleEmojiClick = async (emojiCode: string) => {
const reaction = reactions.find((r) => r.emoji === emojiCode);
const hasReacted =
(reaction &&
reaction.users.find((u) => u.userId === user.id) !== undefined) ||
false;
await handleEmoji({
emoji: emojiCode,
isToRemove: hasReacted,
});
};

let hasMoreReactions = null;
if (reactions.length > MAX_REACTIONS_TO_SHOW) {
hasMoreReactions = reactions.length - MAX_REACTIONS_TO_SHOW;
reactions = reactions.slice(0, MAX_REACTIONS_TO_SHOW);
}

return (
<div className="flex w-full flex-row gap-4">
<div className="flex-shrink-0">
Expand All @@ -39,21 +120,133 @@ export function ConversationMessage({
<div className="min-w-0 break-words text-base font-normal">
{children}
</div>
<div>
{reactions.map((reaction) => {
const hasReacted =
reaction.users.find((u) => u.userId === user.id) !== undefined;
const emoji = emojiData?.emojis[reaction.emoji];
if (!emoji) {
return null;
}
return (
<React.Fragment key={reaction.emoji}>
<a
className="cursor-pointer"
onClick={async () => {
await handleEmoji({
emoji: reaction.emoji,
isToRemove: hasReacted,
});
}}
>
<Reacji
key={reaction.emoji}
count={reaction.users.length}
isHighlighted={hasReacted}
emoji={emoji}
></Reacji>
</a>
</React.Fragment>
);
})}
{hasMoreReactions && (
<span className="text-base text-xs">+{hasMoreReactions}</span>
)}
</div>
</div>
</div>
<div className="flex flex-col items-start gap-2 sm:flex-row">
{buttons &&
buttons.map((button, i) => (
<div key={`message-${messageId}-button-${i}`}>
<IconButton
variant="tertiary"
size="sm"
icon={button.icon}
onClick={button.onClick}
<div className="flex flex-col gap-2">
<div className="flex flex-col items-start gap-2 sm:flex-row">
{buttons &&
buttons.map((button, i) => (
<div key={`message-${messageId}-button-${i}`}>
<Button
variant="tertiary"
size="xs"
label={button.label}
labelVisible={false}
icon={button.icon}
onClick={button.onClick}
/>
</div>
))}
</div>

<div className="duration-400 active:s-border-action-500 box-border inline-flex scale-100 cursor-pointer items-center gap-x-1 whitespace-nowrap rounded-full border border-structure-200 bg-structure-0 px-2 text-xs text-element-800 ">
<a
className="cursor-pointer px-1 py-1.5 hover:border-action-200 hover:bg-action-50 hover:drop-shadow-md active:scale-95 active:bg-action-100 active:drop-shadow-none"
onClick={async () => await handleEmojiClick("+1")}
>
👍
</a>
<a
className="cursor-pointer px-1 py-1.5 hover:border-action-200 hover:bg-action-50 hover:drop-shadow-md active:scale-95 active:bg-action-100 active:drop-shadow-none"
onClick={async () => await handleEmojiClick("-1")}
>
👎
</a>
<a
className="cursor-pointer px-1 py-1.5 hover:border-action-200 hover:bg-action-50 hover:drop-shadow-md active:scale-95 active:bg-action-100 active:drop-shadow-none"
onClick={async () => await handleEmojiClick("heart")}
>
❤️
</a>
<DropdownMenu>
<DropdownMenu.Button>
<IconButton variant="tertiary" size="xs" icon={PlusIcon} />
</DropdownMenu.Button>
<DropdownMenu.Items width={280}>
<Picker
theme="light"
previewPosition="none"
data={emojiData}
onEmojiSelect={async (emojiData: Emoji) => {
const reaction = reactions.find(
(r) => r.emoji === emojiData.id
);
const hasReacted =
(reaction &&
reaction.users.find((u) => u.userId === user.id) !==
undefined) ||
false;
await handleEmoji({
emoji: emojiData.id,
isToRemove: hasReacted,
});
}}
/>
</div>
))}
</DropdownMenu.Items>
</DropdownMenu>
</div>
</div>
</div>
);
}

function Reacji({
count,
isHighlighted,
emoji,
}: {
count: number;
isHighlighted: boolean;
emoji: Emoji;
}) {
const nativeEmoji = emoji.skins[0].native;
if (!nativeEmoji) {
return null;
}
return (
<span className="whitespace-nowrap pr-2">
{nativeEmoji}&nbsp;
<span
className={classNames(
"text-xs",
isHighlighted ? "font-bold text-action-500" : ""
)}
>
{count}
</span>
</span>
);
}
Loading

0 comments on commit 3b08978

Please sign in to comment.