/* * Copyright 2021-2024 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; 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; 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; 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; const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps; const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps; 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 ( {image ? ( ) : null} {(!sender || showSender) && avatar ? ( {!sender ? {avatar} : null} {name} {mode == "pre" ? (
{message}
) : mode == "raw" ? ( message ) : ( {message} )}
{sender ? {avatar} : null}
) : ( {mode == "pre" ? (
{message}
) : mode == "raw" ? ( message ) : ( {message} )}
)}
); }; 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([]); const page = useRef({ key: defaultKey }); const [columns, setColumns] = useState>([]); const scrollDivRef = useRef(null); const anchorDivRef = useRef(null); const isAnchorDivVisible = useElementVisible(anchorDivRef); const [enableSend, setEnableSend] = useState(false); const [showMessage, setShowMessage] = useState(false); const [anchorPopup, setAnchorPopup] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [objectURLs, setObjectURLs] = useState([]); const fileInputRef = useRef(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) : defaultBoxSx, [props.height] ); const onChangeHandler = useCallback((evt: ChangeEvent) => 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) => { 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) => { sendAction(evt.currentTarget.parentElement?.parentElement?.querySelector("input"), "click"); evt.preventDefault(); }, [sendAction] ); const handleFileSelect = useCallback((event: React.ChangeEvent) => { 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({ atype: "info", message: file.size > maxFileSize ? `Image size is limited to ${maxFileSize / 1024} KB` : "Only image file are authorized", system: false, duration: 3000, }) ); 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) { pv[elt.id] = typeof elt.item == "string" ? ( {getInitials(elt.item)} ) : ( ); } return pv; }, {} as Record); }, [users]); const getAvatar = useCallback( (id: string, sender: boolean) => avatars[id] || (sender ? null : ( {getInitials(id)} )), [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) => { 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 ( {rows.length && !rows[0] ? ( ) : null} {rows.map((row, idx) => row ? ( ) : null )} } /> {withInput ? ( <> {imagePreview && ( } onDelete={handleImageDelete} variant="outlined" /> )} ) : undefined, endAdornment: ( ), }, }} sx={inputSx} /> ) : null} {props.children} ); }; export default Chat;