123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620 |
- /*
- * Copyright 2021-2025 Avaiga Private Limited
- *
- * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
- * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations under the License.
- */
- import React, {
- useMemo,
- useCallback,
- KeyboardEvent,
- MouseEvent,
- useState,
- useRef,
- useEffect,
- ReactNode,
- lazy,
- ChangeEvent,
- UIEvent,
- } from "react";
- import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
- import Avatar from "@mui/material/Avatar";
- import Box from "@mui/material/Box";
- import Button from "@mui/material/Button";
- import Chip from "@mui/material/Chip";
- import Grid from "@mui/material/Grid2";
- import IconButton from "@mui/material/IconButton";
- import InputAdornment from "@mui/material/InputAdornment";
- import Paper from "@mui/material/Paper";
- import Popper from "@mui/material/Popper";
- import TextField from "@mui/material/TextField";
- import Tooltip from "@mui/material/Tooltip";
- import Send from "@mui/icons-material/Send";
- import AttachFile from "@mui/icons-material/AttachFile";
- import ArrowDownward from "@mui/icons-material/ArrowDownward";
- import ArrowUpward from "@mui/icons-material/ArrowUpward";
- import {
- createNotificationAction,
- createRequestInfiniteTableUpdateAction,
- createSendActionNameAction,
- } from "../../context/taipyReducers";
- import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
- import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
- import { LoVElt, useLovListMemo } from "./lovUtils";
- import { IconAvatar, avatarSx } from "../../utils/icon";
- import { emptyArray, getInitials } from "../../utils";
- import { RowType, TableValueType } from "./tableUtils";
- import { Stack } from "@mui/material";
- import { getComponentClassName } from "./TaipyStyle";
- import { noDisplayStyle } from "./utils";
- import { toDataUrl } from "../../utils/image";
- const Markdown = lazy(() => import("react-markdown"));
- interface ChatProps extends TaipyActiveProps {
- messages?: TableValueType;
- maxFileSize?: number;
- withInput?: boolean;
- users?: LoVElt[];
- defaultUsers?: string;
- onAction?: string;
- senderId?: string;
- height?: string;
- defaultKey?: string; // for testing purposes only
- pageSize?: number;
- showSender?: boolean;
- mode?: string;
- allowSendImages?: boolean;
- }
- const ENTER_KEY = "Enter";
- const indicWidth = 0.7;
- const avatarWidth = 24;
- const chatAvatarSx = { ...avatarSx, width: avatarWidth, height: avatarWidth };
- const avatarColSx = { width: 1.5 * avatarWidth, minWidth: 1.5 * avatarWidth, pt: 1 };
- const senderMsgSx = {
- width: "fit-content",
- maxWidth: "80%",
- } as SxProps<Theme>;
- const gridSx = { pb: "1em", mt: "unset", flex: 1, overflow: "auto" };
- const loadMoreSx = { width: "fit-content", marginLeft: "auto", marginRight: "auto" };
- const inputSx = { maxWidth: "unset" };
- const leftNameSx = { fontSize: "0.6em", fontWeight: "bolder", pl: `${indicWidth}em` };
- const rightNameSx: SxProps = {
- ...leftNameSx,
- pr: `${2 * indicWidth}em`,
- width: "100%",
- display: "flex",
- justifyContent: "flex-end",
- };
- const senderPaperSx = {
- pr: `${indicWidth}em`,
- pl: `${indicWidth}em`,
- mr: `${indicWidth}em`,
- position: "relative",
- "&:before": {
- content: "''",
- position: "absolute",
- width: "0",
- height: "0",
- borderTopWidth: `${indicWidth}em`,
- borderTopStyle: "solid",
- borderTopColor: (theme: Theme) => theme.palette.background.paper,
- borderLeft: `${indicWidth}em solid transparent`,
- borderRight: `${indicWidth}em solid transparent`,
- top: "0",
- right: `-${indicWidth}em`,
- },
- } as SxProps<Theme>;
- const otherPaperSx = {
- position: "relative",
- pl: `${indicWidth}em`,
- pr: `${indicWidth}em`,
- "&:before": {
- content: "''",
- position: "absolute",
- width: "0",
- height: "0",
- borderTopWidth: `${indicWidth}em`,
- borderTopStyle: "solid",
- borderTopColor: (theme: Theme) => theme.palette.background.paper,
- borderLeft: `${indicWidth}em solid transparent`,
- borderRight: `${indicWidth}em solid transparent`,
- top: "0",
- left: `-${indicWidth}em`,
- },
- } as SxProps<Theme>;
- const defaultBoxSx = {
- pl: `${indicWidth}em`,
- pr: `${indicWidth}em`,
- backgroundColor: (theme: Theme) =>
- theme.palette.mode == "dark"
- ? lighten(theme.palette.background.paper, 0.05)
- : darken(theme.palette.background.paper, 0.15),
- } as SxProps<Theme>;
- const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
- const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
- const imageSx = { width: 3 / 5, height: "auto" };
- interface key2Rows {
- key: string;
- }
- interface ChatRowProps {
- senderId: string;
- message: string;
- image?: string;
- name: string;
- className?: string;
- getAvatar: (id: string, sender: boolean) => ReactNode;
- index: number;
- showSender: boolean;
- mode: string;
- }
- const ChatRow = (props: ChatRowProps) => {
- const { senderId, message, image, name, className, getAvatar, index, showSender, mode } = props;
- const sender = senderId == name;
- const avatar = getAvatar(name, sender);
- return (
- <Grid
- container
- className={getSuffixedClassNames(className, sender ? "-sent" : "-received")}
- size={12}
- sx={noAnchorSx}
- justifyContent={sender ? "flex-end" : undefined}
- >
- <Grid sx={sender ? senderMsgSx : undefined}>
- {image ? (
- <Grid container justifyContent={sender ? "flex-end" : undefined}>
- <Box component="img" sx={imageSx} alt="Uploaded image" src={image} />
- </Grid>
- ) : null}
- {(!sender || showSender) && avatar ? (
- <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
- {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
- <Stack>
- <Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
- <Paper
- sx={sender ? senderPaperSx : otherPaperSx}
- data-idx={index}
- className={getSuffixedClassNames(className, "-" + mode)}
- >
- {mode == "pre" ? (
- <pre>{message}</pre>
- ) : mode == "raw" ? (
- message
- ) : (
- <Markdown>{message}</Markdown>
- )}
- </Paper>
- </Stack>
- {sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
- </Stack>
- ) : (
- <Paper
- sx={sender ? senderPaperSx : otherPaperSx}
- data-idx={index}
- className={getSuffixedClassNames(className, mode)}
- >
- {mode == "pre" ? (
- <pre>{message}</pre>
- ) : mode == "raw" ? (
- message
- ) : (
- <Markdown>{message}</Markdown>
- )}
- </Paper>
- )}
- </Grid>
- </Grid>
- );
- };
- const getChatKey = (start: number, page: number) => `Chat-${start}-${start + page}`;
- const Chat = (props: ChatProps) => {
- const {
- id,
- updateVarName,
- senderId = "taipy",
- onAction,
- withInput = true,
- defaultKey = "",
- maxFileSize = .8 * 1024 * 1024, // 0.8 MB
- pageSize = 50,
- showSender = false,
- allowSendImages = true,
- } = props;
- const dispatch = useDispatch();
- const module = useModule();
- const [rows, setRows] = useState<RowType[]>([]);
- const page = useRef<key2Rows>({ key: defaultKey });
- const [columns, setColumns] = useState<Array<string>>([]);
- const scrollDivRef = useRef<HTMLDivElement>(null);
- const anchorDivRef = useRef<HTMLElement>(null);
- const isAnchorDivVisible = useElementVisible(anchorDivRef);
- const [enableSend, setEnableSend] = useState(false);
- const [showMessage, setShowMessage] = useState(false);
- const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
- const [selectedFile, setSelectedFile] = useState<File | null>(null);
- const [imagePreview, setImagePreview] = useState<string | null>(null);
- const [objectURLs, setObjectURLs] = useState<string[]>([]);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const userScrolled = useRef(false);
- const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
- const active = useDynamicProperty(props.active, props.defaultActive, true);
- const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
- const users = useLovListMemo(props.users, props.defaultUsers || "");
- const mode = useMemo(
- () => (["pre", "raw"].includes(props.mode || "") ? (props.mode as string) : "markdown"),
- [props.mode]
- );
- const boxSx = useMemo(
- () =>
- props.height
- ? ({
- ...defaultBoxSx,
- maxHeight: "" + Number(props.height) == "" + props.height ? props.height + "px" : props.height,
- display: "flex",
- flexDirection: "column",
- } as SxProps<Theme>)
- : defaultBoxSx,
- [props.height]
- );
- const onChangeHandler = useCallback((evt: ChangeEvent<HTMLInputElement>) => setEnableSend(!!evt.target.value), []);
- const sendAction = useCallback(
- (elt: HTMLInputElement | null | undefined, reason: string) => {
- if (elt && (elt?.value || imagePreview)) {
- toDataUrl(imagePreview)
- .then((dataUrl) => {
- dispatch(
- createSendActionNameAction(
- id,
- module,
- onAction,
- reason,
- updateVarName,
- elt?.value,
- senderId,
- dataUrl
- )
- );
- elt.value = "";
- setSelectedFile(null);
- setImagePreview((url) => {
- url && URL.revokeObjectURL(url);
- return null;
- });
- fileInputRef.current && (fileInputRef.current.value = "");
- })
- .catch(console.log);
- }
- },
- [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
- );
- const handleAction = useCallback(
- (evt: KeyboardEvent<HTMLDivElement>) => {
- if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
- sendAction(evt.currentTarget.querySelector("input"), evt.key);
- evt.preventDefault();
- }
- },
- [sendAction]
- );
- const handleClick = useCallback(
- (evt: MouseEvent<HTMLButtonElement>) => {
- sendAction(evt.currentTarget.parentElement?.parentElement?.querySelector("input"), "click");
- evt.preventDefault();
- },
- [sendAction]
- );
- const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
- const file = event.target.files ? event.target.files[0] : null;
- if (file) {
- if (file.type.startsWith("image/") && file.size <= maxFileSize) {
- setSelectedFile(file);
- const newImagePreview = URL.createObjectURL(file);
- setImagePreview(newImagePreview);
- setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
- } else {
- dispatch(
- createNotificationAction({
- nType: "info",
- message:
- file.size > maxFileSize
- ? `Image size is limited to ${maxFileSize / 1024} KB`
- : "Only image file are authorized",
- system: false,
- duration: 3000,
- snackbarId: "Chat warning"
- })
- );
- setSelectedFile(null);
- setImagePreview(null);
- fileInputRef.current && (fileInputRef.current.value = "");
- }
- }
- }, [maxFileSize, dispatch]);
- const handleAttachClick = useCallback(() => fileInputRef.current && fileInputRef.current.click(), [fileInputRef]);
- const handleImageDelete = useCallback(() => {
- setSelectedFile(null);
- setImagePreview((url) => {
- url && URL.revokeObjectURL(url);
- return null;
- });
- fileInputRef.current && (fileInputRef.current.value = "");
- }, []);
- const avatars = useMemo(() => {
- return users.reduce((pv, elt) => {
- if (elt.id) {
- const id = elt.id.startsWith("<taipy.gui.icon.Icon") && typeof elt.item !== "string" ? elt.item.text : elt.id;
- pv[id] =
- typeof elt.item == "string" ? (
- <Tooltip title={elt.item}>
- <Avatar sx={chatAvatarSx}>{getInitials(elt.item)}</Avatar>
- </Tooltip>
- ) : (
- <IconAvatar img={elt.item} sx={chatAvatarSx} />
- );
- }
- return pv;
- }, {} as Record<string, React.ReactNode>);
- }, [users]);
- const getAvatar = useCallback(
- (id: string, sender: boolean) =>
- avatars[id] ||
- (sender ? null : (
- <Tooltip title={id}>
- <Avatar sx={chatAvatarSx}>{getInitials(id)}</Avatar>
- </Tooltip>
- )),
- [avatars]
- );
- const loadMoreItems = useCallback(
- (startIndex: number) => {
- const key = getChatKey(startIndex, pageSize);
- page.current = {
- key: key,
- };
- dispatch(
- createRequestInfiniteTableUpdateAction(
- updateVarName,
- id,
- module,
- [],
- key,
- startIndex,
- startIndex + pageSize,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- undefined,
- true // reverse
- )
- );
- },
- [pageSize, updateVarName, id, dispatch, module]
- );
- const showBottom = useCallback(() => {
- anchorDivRef.current?.scrollIntoView && anchorDivRef.current?.scrollIntoView();
- setShowMessage(false);
- }, []);
- const refresh = props.messages?.__taipy_refresh !== undefined;
- useEffect(() => {
- if (!refresh && props.messages && page.current.key && props.messages[page.current.key] !== undefined) {
- const newValue = props.messages[page.current.key];
- if (newValue.rowcount == 0) {
- setRows(emptyArray);
- } else {
- const nr = newValue.data as RowType[];
- if (Array.isArray(nr) && nr.length > newValue.start && nr[newValue.start]) {
- setRows((old) => {
- old.length && nr.length > old.length && setShowMessage(true);
- if (newValue.start > 0 && old.length > newValue.start) {
- return old.slice(0, newValue.start).concat(nr.slice(newValue.start));
- }
- return nr;
- });
- const cols = Object.keys(nr[newValue.start]);
- setColumns(cols.length > 2 ? cols : cols.length == 2 ? [...cols, ""] : ["", ...cols, "", ""]);
- }
- }
- page.current.key = getChatKey(0, pageSize);
- !userScrolled.current && showBottom();
- }
- }, [refresh, pageSize, props.messages, showBottom]);
- useEffect(() => {
- if (showMessage && !isAnchorDivVisible) {
- setAnchorPopup(scrollDivRef.current);
- setTimeout(() => setShowMessage(false), 5000);
- } else if (!showMessage) {
- setAnchorPopup(null);
- }
- }, [showMessage, isAnchorDivVisible]);
- useEffect(() => {
- if (refresh) {
- Promise.resolve().then(() => loadMoreItems(0)); // So that the state can be changed
- }
- }, [refresh, loadMoreItems]);
- useEffect(() => {
- loadMoreItems(0);
- }, [loadMoreItems]);
- useEffect(() => {
- return () => {
- for (const objectURL of objectURLs) {
- URL.revokeObjectURL(objectURL);
- }
- };
- }, [objectURLs]);
- const loadOlder = useCallback(
- (evt: MouseEvent<HTMLElement>) => {
- const { start } = evt.currentTarget.dataset;
- if (start) {
- loadMoreItems(parseInt(start));
- }
- },
- [loadMoreItems]
- );
- const handleOnScroll = useCallback((evt: UIEvent) => {
- userScrolled.current = (evt.target as HTMLDivElement).scrollHeight - (evt.target as HTMLDivElement).offsetHeight - (evt.target as HTMLDivElement).scrollTop > 1;
- }, []);
- return (
- <Tooltip title={hover || ""}>
- <Paper className={`${className} ${getComponentClassName(props.children)}`} sx={boxSx} id={id}>
- <Grid container rowSpacing={2} sx={gridSx} ref={scrollDivRef} onScroll={handleOnScroll}>
- {rows.length && !rows[0] ? (
- <Grid className={getSuffixedClassNames(className, "-load")} size={12} sx={noAnchorSx}>
- <Box sx={loadMoreSx}>
- <Button
- endIcon={<ArrowUpward />}
- onClick={loadOlder}
- data-start={rows.length - rows.findIndex((row) => !!row)}
- >
- Load More
- </Button>
- </Box>
- </Grid>
- ) : null}
- {rows.map((row, idx) =>
- row ? (
- <ChatRow
- key={columns[0] ? `${row[columns[0]]}` : `id${idx}`}
- senderId={senderId}
- message={`${row[columns[1]]}`}
- name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
- image={
- columns[3] && columns[3] != "_tp_index" && row[columns[3]]
- ? `${row[columns[3]]}`
- : undefined
- }
- className={className}
- getAvatar={getAvatar}
- index={idx}
- showSender={showSender}
- mode={mode}
- />
- ) : null
- )}
- <Box sx={anchorSx} ref={anchorDivRef} />
- </Grid>
- <Popper id={id} open={Boolean(anchorPopup)} anchorEl={anchorPopup} placement="right">
- <Chip
- label="A new message is available"
- variant="outlined"
- onClick={showBottom}
- icon={<ArrowDownward />}
- />
- </Popper>
- {withInput ? (
- <>
- {imagePreview && (
- <Box mb={1}>
- <Chip
- label={selectedFile?.name}
- avatar={<Avatar alt="Image preview" src={imagePreview} />}
- onDelete={handleImageDelete}
- variant="outlined"
- />
- </Box>
- )}
- <input
- type="file"
- ref={fileInputRef}
- style={noDisplayStyle}
- onChange={handleFileSelect}
- accept="image/*"
- />
- <TextField
- margin="dense"
- fullWidth
- onChange={onChangeHandler}
- className={getSuffixedClassNames(className, "-input")}
- label={`message (${senderId})`}
- disabled={!active}
- onKeyDown={handleAction}
- slotProps={{
- input: {
- startAdornment: allowSendImages ? (
- <InputAdornment position="start">
- <IconButton
- aria-label="upload image"
- onClick={handleAttachClick}
- edge="start"
- disabled={!active}
- >
- <AttachFile color={disableColor("primary", !active)} />
- </IconButton>
- </InputAdornment>
- ) : undefined,
- endAdornment: (
- <InputAdornment position="end">
- <IconButton
- aria-label="send message"
- onClick={handleClick}
- edge="end"
- disabled={!active || !(enableSend || imagePreview)}
- >
- <Send
- color={disableColor(
- "primary",
- !active || !(enableSend || imagePreview)
- )}
- />
- </IconButton>
- </InputAdornment>
- ),
- },
- }}
- sx={inputSx}
- />
- </>
- ) : null}
- {props.children}
- </Paper>
- </Tooltip>
- );
- };
- export default Chat;
|