Add replies and emoji reactions to chat messages
Every message has a row under it to reply or react. Replying quotes the message above the composer, and the sent message stores reply_to_message_id (a plain id, not a foreign key, so deleting the quoted message only drops the quote). send-message accepts a quote from any session of the same conversation, so a rollover or a thread that moved on still sends; the conversation drops a picked quote when its session changes. The bot reads the quote as "[Replying to ...]" before the user's turn, file-only replies included, and relayed messages carry it too. Reactions are a fixed set (BotMessageReactions) in bot_message_reactions, one per message, emoji and user or bot. Users toggle their own through api/admin/bots/react-message, and every bot gets react_to_message for the session it answers (the user's latest message by default). Both push the fresh list as server:bot-reaction, with mine worked out for the session's user. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
|
||||
/**
|
||||
* The conversation's reply and reaction handlers, shared by every
|
||||
* message's actions.
|
||||
*/
|
||||
export type BotMessageActions = {
|
||||
onReply: (params: { message: BotMessageWithAttachments }) => void;
|
||||
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
|
||||
};
|
||||
|
||||
export const BotMessageActionsContext = React.createContext<BotMessageActions>({
|
||||
onReply: () => {},
|
||||
onReact: () => {},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { ReactToBotMessageRequest } from "@/src/schemas/bot-schema";
|
||||
import type { BotMessageReactionData } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Adds or takes back an emoji reaction on a message, returning the
|
||||
* message's fresh reactions.
|
||||
*/
|
||||
export default async function reactToBotMessage({ message_id, emoji }: ReactToBotMessageRequest) {
|
||||
return await fetchApi<
|
||||
ReactToBotMessageRequest,
|
||||
APIResponseObject<BotMessageReactionData>
|
||||
>(`/api/admin/bots/react-message`, {
|
||||
method: "POST",
|
||||
body: { message_id, emoji },
|
||||
});
|
||||
}
|
||||
@@ -8,12 +8,13 @@ export default async function sendBotMessage({
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids,
|
||||
reply_to_message_id,
|
||||
}: SendBotMessageRequest) {
|
||||
return await fetchApi<
|
||||
SendBotMessageRequest,
|
||||
APIResponseObject<BotMessageWithAttachments>
|
||||
>(`/api/admin/bots/send-message`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id, text, media_ids },
|
||||
body: { bot_id, workspace_id, text, media_ids, reply_to_message_id },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,9 +29,8 @@ export default function BotMessageGroup({ bot, group }: Props) {
|
||||
{group.messages.map((message, index) => (
|
||||
<BotMessageLine
|
||||
key={message.id}
|
||||
message_id={message.id || null}
|
||||
text={message.text || ""}
|
||||
needs_you={Boolean(message.needs_you)}
|
||||
message={message}
|
||||
bot_name={bot.name || "the bot"}
|
||||
at={index == group.messages.length - 1 ? group.at : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useContext } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageReactions from "./message-reactions";
|
||||
import MessageReplyQuote from "./message-reply-quote";
|
||||
import NeedsYouChip from "./needs-you-chip";
|
||||
import SpeakMessageButton from "./speak-message-button";
|
||||
|
||||
type Props = {
|
||||
message_id?: number | null;
|
||||
text: string;
|
||||
needs_you?: boolean;
|
||||
message: BotMessageWithAttachments;
|
||||
bot_name: string;
|
||||
/**
|
||||
* The group's time, on the last message only: shown beside its play
|
||||
* button.
|
||||
@@ -22,32 +27,40 @@ type Props = {
|
||||
/**
|
||||
* One bot message, rendered from markdown (GFM; raw HTML is never
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them. A play button under it reads it aloud,
|
||||
* disabled while the bot has no speech model, with the group's time
|
||||
* before it on the last message.
|
||||
* switch the bot to them. A quoted reply sits above it, its reactions
|
||||
* below, and a row under it replies, reacts, reads it aloud and shows
|
||||
* why it needs you.
|
||||
*/
|
||||
export default function BotMessageLine({ message_id, text, needs_you, at }: Props) {
|
||||
const can_speak = Boolean(message_id && text.trim());
|
||||
export default function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
const { onReact } = useContext(BotMessageActionsContext);
|
||||
const can_speak = Boolean(message.id && message.text?.trim());
|
||||
|
||||
return (
|
||||
<Stack className="gap-1.5! items-start max-w-full">
|
||||
<Stack className="w-full min-w-0 gap-1.5! items-start max-w-full">
|
||||
{message.reply_to ? (
|
||||
<MessageReplyQuote reply_to={message.reply_to} bot_name={bot_name} />
|
||||
) : null}
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={MessageMarkdownComponents}
|
||||
>
|
||||
{linkComputerRefs({ text })}
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
{needs_you || can_speak || at ? (
|
||||
<Row className="gap-2! items-center">
|
||||
{needs_you ? <NeedsYouChip /> : null}
|
||||
{at ? <GroupTimestamp at={at} /> : null}
|
||||
{can_speak ? (
|
||||
<SpeakMessageButton message_id={message_id!} />
|
||||
) : null}
|
||||
</Row>
|
||||
{message.id ? (
|
||||
<MessageReactions
|
||||
message_id={message.id}
|
||||
reactions={message.reactions}
|
||||
onReact={onReact}
|
||||
/>
|
||||
) : null}
|
||||
<Row className="gap-2! items-center">
|
||||
{message.needs_you ? <NeedsYouChip /> : null}
|
||||
{at ? <GroupTimestamp at={at} /> : null}
|
||||
{can_speak ? <SpeakMessageButton message_id={Number(message.id)} /> : null}
|
||||
<MessageActions message={message} />
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Reply, X } from "lucide-react";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import messagePreview from "@/src/utils/message-preview";
|
||||
|
||||
type Props = {
|
||||
message: BotMessageWithAttachments;
|
||||
bot_name: string;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The message the next one will quote, above the composer, until it is
|
||||
* sent or dropped.
|
||||
*/
|
||||
export default function ComposerReplyPreview({ message, bot_name, onCancel }: Props) {
|
||||
const preview = messagePreview({ text: message.text || "" }) || "A file";
|
||||
|
||||
return (
|
||||
<Row className="w-full max-w-container mx-auto gap-2! flex-nowrap items-center px-3 md:px-6 pt-4 pb-0">
|
||||
<Row className="min-w-0 grow gap-2! flex-nowrap items-center overflow-hidden rounded-default-lg border border-solid border-hairline bg-surface px-3 py-1.5">
|
||||
<Reply size={14} aria-hidden className="shrink-0 text-ink-secondary" />
|
||||
<Stack className="min-w-0 grow gap-0.5!">
|
||||
<Span className="block w-full truncate text-[11px] font-bold leading-4 text-ink">
|
||||
Replying to {message.author == "user" ? "yourself" : bot_name}
|
||||
</Span>
|
||||
<Span className="block w-full truncate text-xs leading-4 text-ink-secondary">
|
||||
{preview}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
<Button
|
||||
title="Cancel reply"
|
||||
aria-label="Cancel reply"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
className="shrink-0 p-1!"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
@@ -18,6 +18,7 @@ import useVoiceRecorder from "../(hooks)/use-voice-recorder";
|
||||
import useOnlineStatus from "@/src/hooks/use-online-status";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerReplyPreview from "./composer-reply-preview";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
import ComposerVoiceButton from "./composer-voice-button";
|
||||
@@ -36,6 +37,11 @@ type Props = {
|
||||
* The dictation mic is disabled while transcription has no model.
|
||||
*/
|
||||
voice?: BotVoicePayload | null;
|
||||
/**
|
||||
* The message the next one quotes, if the user picked one.
|
||||
*/
|
||||
reply_to?: BotMessageWithAttachments | null;
|
||||
onCancelReply?: () => void;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||
};
|
||||
|
||||
@@ -48,7 +54,15 @@ type Props = {
|
||||
* are listed under the input. The mic dictates: what's said is written
|
||||
* into the input at the cursor, to edit before sending.
|
||||
*/
|
||||
export default function Composer({ bot, workspace_id, extensions, voice, onSent }: Props) {
|
||||
export default function Composer({
|
||||
bot,
|
||||
workspace_id,
|
||||
extensions,
|
||||
voice,
|
||||
reply_to,
|
||||
onCancelReply,
|
||||
onSent,
|
||||
}: Props) {
|
||||
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -58,6 +72,13 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
|
||||
|
||||
useAutoGrowTextarea({ ref: input_ref, value: text });
|
||||
|
||||
/**
|
||||
* Picking a message to reply to puts the cursor back in the input.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (reply_to) input_ref.current?.focus();
|
||||
}, [reply_to]);
|
||||
|
||||
const recorder = useVoiceRecorder({
|
||||
onRecorded: async ({ audio }) => {
|
||||
if (!bot.id) return;
|
||||
@@ -115,6 +136,7 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids: files.media_ids,
|
||||
reply_to_message_id: reply_to?.id || null,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
@@ -156,7 +178,14 @@ export default function Composer({ bot, workspace_id, extensions, voice, onSent
|
||||
onRemove={files.remove}
|
||||
/>
|
||||
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
|
||||
<Row className="w-full max-w-container mx-auto min-h-[88px] py-7 gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text"
|
||||
{reply_to && onCancelReply ? (
|
||||
<ComposerReplyPreview
|
||||
message={reply_to}
|
||||
bot_name={bot.name || "the bot"}
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
) : null}
|
||||
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
aria_label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A small ghost icon button under a message, for its reply and reaction
|
||||
* actions. Quiet until hovered, and readable in both themes.
|
||||
*/
|
||||
export default function MessageActionButton({ title, aria_label, onClick, children }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
aria-label={aria_label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onClick}
|
||||
className="shrink-0 p-1! -mx-1 text-ink-secondary dark:text-ink-secondary hover:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { Reply } from "lucide-react";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import MessageActionButton from "./message-action-button";
|
||||
import MessageReactionPicker from "./message-reaction-picker";
|
||||
|
||||
type Props = {
|
||||
message: BotMessageWithAttachments;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message's reply and reaction actions, shown under it.
|
||||
*/
|
||||
export default function MessageActions({ message }: Props) {
|
||||
const { onReply, onReact } = useContext(BotMessageActionsContext);
|
||||
|
||||
if (!message.id) return null;
|
||||
|
||||
return (
|
||||
<Row className="gap-1.5! items-center">
|
||||
<MessageActionButton
|
||||
title="Reply"
|
||||
aria_label="Reply to this message"
|
||||
onClick={() => onReply({ message })}
|
||||
>
|
||||
<Reply size={14} aria-hidden />
|
||||
</MessageActionButton>
|
||||
<MessageReactionPicker message_id={message.id} onReact={onReact} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
|
||||
export default function MessageGroupBlock({ bot, group }: Props) {
|
||||
if (group.author === "user") {
|
||||
return <UserMessageGroup group={group} />;
|
||||
return <UserMessageGroup bot={bot} group={group} />;
|
||||
}
|
||||
|
||||
return <BotMessageGroup bot={bot} group={group} />;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { BotMessageReaction } from "@/src/types";
|
||||
|
||||
type Props = BotMessageReaction & {
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One emoji reaction under a message: its count, highlighted when the
|
||||
* user gave it. Clicking takes it back or adds it.
|
||||
*/
|
||||
export default function MessageReactionChip({ emoji, count, mine, onToggle }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={mine ? "Remove your reaction" : "Add this reaction"}
|
||||
aria-label={mine ? "Remove your reaction" : "Add this reaction"}
|
||||
aria-pressed={mine}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onToggle}
|
||||
className={`shrink-0 gap-1! px-2! py-0.5! rounded-default-lg border border-solid text-xs leading-4 ${
|
||||
mine
|
||||
? "border-hairline-strong bg-sunken text-ink"
|
||||
: "border-hairline bg-surface text-ink-secondary hover:text-ink"
|
||||
} focus-visible:ring-2 focus-visible:ring-ink`}
|
||||
>
|
||||
<Span className="text-sm leading-4">{emoji}</Span>
|
||||
{count > 1 ? <Span className="tabular-nums">{count}</Span> : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
value: string;
|
||||
onPick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One emoji in the reaction picker.
|
||||
*/
|
||||
export default function MessageReactionOption({ title, value, onPick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
aria-label={`React with ${title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onPick}
|
||||
className="shrink-0 px-2! py-1! rounded-default-xs hover:bg-sunken focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Span className="text-base leading-5">{value}</Span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import Dropdown from "@moduletrace/twui/elements/Dropdown";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import { BotMessageReactions, type BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import MessageActionButton from "./message-action-button";
|
||||
import MessageReactionOption from "./message-reaction-option";
|
||||
|
||||
type Props = {
|
||||
message_id: number;
|
||||
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the fixed set of reactions and adds one to the message. The
|
||||
* picker closes as soon as one is picked.
|
||||
*/
|
||||
export default function MessageReactionPicker({ message_id, onReact }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
position="top-left"
|
||||
externalOpen={open}
|
||||
externalSetOpen={setOpen}
|
||||
contentWrapperProps={{ className: "pb-1" }}
|
||||
target={
|
||||
<MessageActionButton
|
||||
title="Add a reaction"
|
||||
aria_label="Add a reaction"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<SmilePlus size={14} aria-hidden />
|
||||
</MessageActionButton>
|
||||
}
|
||||
>
|
||||
<Row className="gap-0! flex-nowrap items-center rounded-default-lg border border-solid border-hairline bg-surface p-1 shadow-popover">
|
||||
{BotMessageReactions.map((reaction) => (
|
||||
<MessageReactionOption
|
||||
key={reaction.value}
|
||||
title={reaction.title}
|
||||
value={reaction.value}
|
||||
onPick={() => {
|
||||
setOpen(false);
|
||||
onReact({ message_id, emoji: reaction.value });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import type { BotMessageReaction } from "@/src/types";
|
||||
import MessageReactionChip from "./message-reaction-chip";
|
||||
|
||||
type Props = {
|
||||
message_id: number;
|
||||
reactions?: BotMessageReaction[];
|
||||
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The reactions a message has, one chip per emoji, each toggleable.
|
||||
*/
|
||||
export default function MessageReactions({ message_id, reactions, onReact }: Props) {
|
||||
if (!reactions?.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="gap-1.5! flex-wrap items-center">
|
||||
{reactions.map((reaction) => (
|
||||
<MessageReactionChip
|
||||
key={reaction.emoji}
|
||||
emoji={reaction.emoji}
|
||||
count={reaction.count}
|
||||
mine={reaction.mine}
|
||||
onToggle={() => onReact({ message_id, emoji: reaction.emoji })}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Reply } from "lucide-react";
|
||||
import type { BotMessageReply } from "@/src/types";
|
||||
import messagePreview from "@/src/utils/message-preview";
|
||||
|
||||
type Props = {
|
||||
reply_to: BotMessageReply;
|
||||
bot_name: string;
|
||||
/**
|
||||
* Right-align it, for a user message.
|
||||
*/
|
||||
align_end?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The message a reply answers: who wrote it and a one-line preview. A
|
||||
* file-only message says so instead of showing an empty line.
|
||||
*/
|
||||
export default function MessageReplyQuote({ reply_to, bot_name, align_end }: Props) {
|
||||
const preview = messagePreview({ text: reply_to.text }) || "A file";
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className={`w-full max-w-[80%] min-w-0 gap-0.5! overflow-hidden border-0 border-l-2 border-solid border-hairline-strong pl-2.5 ${
|
||||
align_end ? "items-end text-right" : "items-start"
|
||||
}`}
|
||||
>
|
||||
<Row className="max-w-full min-w-0 gap-1! items-center">
|
||||
<Reply size={11} aria-hidden className="shrink-0 text-ink-secondary" />
|
||||
<Span className="min-w-0 truncate text-[11px] font-bold leading-4 text-ink">
|
||||
{reply_to.author == "user" ? "You" : bot_name}
|
||||
</Span>
|
||||
</Row>
|
||||
<Span className="block w-full max-w-full break-words text-xs leading-4 text-ink-secondary line-clamp-2">
|
||||
{preview}
|
||||
</Span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserMessage from "./user-message";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Right-aligned ink bubbles (each message's files above it), one
|
||||
* timestamp under the group.
|
||||
*/
|
||||
export default function UserMessageGroup({ group }: { group: MessageGroup }) {
|
||||
export default function UserMessageGroup({ bot, group }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-1! items-end">
|
||||
{group.messages.map((message) => (
|
||||
<UserMessage key={message.id} {...message} />
|
||||
<UserMessage key={message.id} message={message} bot_name={bot.name || "the bot"} />
|
||||
))}
|
||||
<GroupTimestamp at={group.at} />
|
||||
</Stack>
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { useContext } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageAttachments from "./message-attachments";
|
||||
import MessageReactions from "./message-reactions";
|
||||
import MessageReplyQuote from "./message-reply-quote";
|
||||
import UserBubble from "./user-bubble";
|
||||
|
||||
type Props = {
|
||||
message: BotMessageWithAttachments;
|
||||
bot_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One user message: its files, then its text (a file-only message has no
|
||||
* bubble).
|
||||
* One user message: the quoted message it answers, its files, then its
|
||||
* text (a file-only message has no bubble), its reactions and actions.
|
||||
*/
|
||||
export default function UserMessage({ text, attachments }: BotMessageWithAttachments) {
|
||||
export default function UserMessage({ message, bot_name }: Props) {
|
||||
const { onReact } = useContext(BotMessageActionsContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageAttachments attachments={attachments} />
|
||||
{text ? <UserBubble text={text} /> : null}
|
||||
</>
|
||||
<Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
|
||||
{message.reply_to ? (
|
||||
<MessageReplyQuote
|
||||
reply_to={message.reply_to}
|
||||
bot_name={bot_name}
|
||||
align_end
|
||||
/>
|
||||
) : null}
|
||||
<MessageAttachments attachments={message.attachments} />
|
||||
{message.text ? <UserBubble text={message.text} /> : null}
|
||||
{message.id ? (
|
||||
<MessageReactions
|
||||
message_id={message.id}
|
||||
reactions={message.reactions}
|
||||
onReact={onReact}
|
||||
/>
|
||||
) : null}
|
||||
<MessageActions message={message} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import type {
|
||||
BotMessageReactionData,
|
||||
BotMessageWithAttachments,
|
||||
} from "@/src/types";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
||||
import reactToBotMessage from "../(functions)/react-to-bot-message";
|
||||
import useBotComputers from "../(hooks)/use-bot-computers";
|
||||
import useTaskProgress from "../(hooks)/use-task-progress";
|
||||
import useReplyProgress from "../(hooks)/use-reply-progress";
|
||||
@@ -37,6 +43,14 @@ export default function Conversation() {
|
||||
);
|
||||
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
|
||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(null);
|
||||
|
||||
/**
|
||||
* A quote picked in one session doesn't follow the thread to another.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setReplyTo(null);
|
||||
}, [session_id]);
|
||||
|
||||
const bots = useLiveBots({
|
||||
bots: pageProps?.bots || [],
|
||||
@@ -113,12 +127,44 @@ export default function Conversation() {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A reaction toggled here or in another tab: the pushed list replaces
|
||||
* the message's own.
|
||||
*/
|
||||
const applyReactions = ({ message_id, reactions }: BotMessageReactionData) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
Number(message.id) == message_id ? { ...message, reactions } : message,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
useServerEvent({ event: "server:bot-reaction", onData: applyReactions });
|
||||
|
||||
const reactToMessage = async ({
|
||||
message_id,
|
||||
emoji,
|
||||
}: {
|
||||
message_id: number;
|
||||
emoji: BotMessageReactionEmoji;
|
||||
}) => {
|
||||
const res = await reactToBotMessage({ message_id, emoji });
|
||||
|
||||
if (res.success && res.singleRes) applyReactions(res.singleRes);
|
||||
};
|
||||
|
||||
if (!bot?.id || !live_bot) return null;
|
||||
|
||||
return (
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<BotMessageActionsContext.Provider
|
||||
value={{
|
||||
onReply: ({ message }) => setReplyTo(message),
|
||||
onReact: reactToMessage,
|
||||
}}
|
||||
>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
workspace={workspace}
|
||||
@@ -137,7 +183,11 @@ export default function Conversation() {
|
||||
workspace_id={workspace_id}
|
||||
extensions={pageProps?.bot_extensions}
|
||||
voice={pageProps?.bot_voice}
|
||||
reply_to={reply_to}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSent={({ message }) => {
|
||||
setReplyTo(null);
|
||||
|
||||
if (startsNewSession(message) && message.session_id != session_id) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
@@ -151,7 +201,8 @@ export default function Conversation() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BotMessageActionsContext.Provider>
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
</SpeakMessageContext.Provider>
|
||||
</BotComputersContext.Provider>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
|
||||
import toggleMessageReaction from "@/src/functions/backend/db/bots/toggle-message-reaction";
|
||||
import pushToSessionUser from "@/src/bots-queue/push-to-session-user";
|
||||
import { ReactToBotMessageRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
|
||||
/**
|
||||
* Adds or takes back one of the fixed emoji reactions on a message in the
|
||||
* session user's own conversation, and pushes the message's fresh
|
||||
* reactions to their other open tabs.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { message_id, emoji } = parseWithSchema({
|
||||
schema: ReactToBotMessageRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const message = await grabUserBotMessage({ user_id: user.id, message_id });
|
||||
const reactions = await toggleMessageReaction({
|
||||
message_id: Number(message.id),
|
||||
user_id: user.id,
|
||||
emoji,
|
||||
});
|
||||
|
||||
await pushToSessionUser({
|
||||
session_id: message.session_id,
|
||||
data: {
|
||||
event: "server:bot-reaction",
|
||||
data: { message_id: Number(message.id), reactions },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: { message_id: Number(message.id), reactions },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
||||
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
|
||||
import grabUserBotMessage from "@/src/functions/backend/db/bots/grab-user-bot-message";
|
||||
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
|
||||
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
@@ -47,7 +48,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id, text, media_ids } = parseWithSchema({
|
||||
const { bot_id, workspace_id, text, media_ids, reply_to_message_id } = parseWithSchema({
|
||||
schema: SendBotMessageRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
@@ -63,11 +64,32 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
rollover: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* The quoted message only has to be in this conversation, not in
|
||||
* the session the message lands in: sending can roll over to a new
|
||||
* one, or the thread may have moved on.
|
||||
*/
|
||||
const reply_to = reply_to_message_id
|
||||
? await grabUserBotMessage({ user_id: user.id, message_id: reply_to_message_id }).catch(
|
||||
() => null,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (
|
||||
reply_to_message_id &&
|
||||
(!reply_to ||
|
||||
reply_to.bot_id != bot.id ||
|
||||
(reply_to.workspace_id || null) != (conversation.workspace_id || null))
|
||||
) {
|
||||
throw new AppError(`That message is no longer in this conversation.`);
|
||||
}
|
||||
|
||||
const message = await insertUserMessage({
|
||||
bot_id: bot.id,
|
||||
session_id: Number(session.id),
|
||||
workspace_id: conversation.workspace_id,
|
||||
text,
|
||||
reply_to,
|
||||
media,
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export const handler: BunextAPIRouteHandler<
|
||||
payload: await grabBotMessages({
|
||||
session_id: session.id,
|
||||
limit: AppData.BotReplyCatchUpMessages,
|
||||
user_id: user.id,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
|
||||
Reference in New Issue
Block a user