Chat.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. /*
  2. * Copyright 2021-2025 Avaiga Private Limited
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  5. * the License. You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  10. * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  11. * specific language governing permissions and limitations under the License.
  12. */
  13. import React, {
  14. useMemo,
  15. useCallback,
  16. KeyboardEvent,
  17. MouseEvent,
  18. useState,
  19. useRef,
  20. useEffect,
  21. ReactNode,
  22. lazy,
  23. ChangeEvent,
  24. UIEvent,
  25. } from "react";
  26. import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
  27. import Avatar from "@mui/material/Avatar";
  28. import Box from "@mui/material/Box";
  29. import Button from "@mui/material/Button";
  30. import Chip from "@mui/material/Chip";
  31. import Grid from "@mui/material/Grid2";
  32. import IconButton from "@mui/material/IconButton";
  33. import InputAdornment from "@mui/material/InputAdornment";
  34. import Paper from "@mui/material/Paper";
  35. import Popper from "@mui/material/Popper";
  36. import TextField from "@mui/material/TextField";
  37. import Tooltip from "@mui/material/Tooltip";
  38. import Send from "@mui/icons-material/Send";
  39. import AttachFile from "@mui/icons-material/AttachFile";
  40. import ArrowDownward from "@mui/icons-material/ArrowDownward";
  41. import ArrowUpward from "@mui/icons-material/ArrowUpward";
  42. import {
  43. createNotificationAction,
  44. createRequestInfiniteTableUpdateAction,
  45. createSendActionNameAction,
  46. } from "../../context/taipyReducers";
  47. import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
  48. import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
  49. import { LoVElt, useLovListMemo } from "./lovUtils";
  50. import { IconAvatar, avatarSx } from "../../utils/icon";
  51. import { emptyArray, getInitials } from "../../utils";
  52. import { RowType, TableValueType } from "./tableUtils";
  53. import { Stack } from "@mui/material";
  54. import { getComponentClassName } from "./TaipyStyle";
  55. import { noDisplayStyle } from "./utils";
  56. import { toDataUrl } from "../../utils/image";
  57. const Markdown = lazy(() => import("react-markdown"));
  58. interface ChatProps extends TaipyActiveProps {
  59. messages?: TableValueType;
  60. maxFileSize?: number;
  61. withInput?: boolean;
  62. users?: LoVElt[];
  63. defaultUsers?: string;
  64. onAction?: string;
  65. senderId?: string;
  66. height?: string;
  67. defaultKey?: string; // for testing purposes only
  68. pageSize?: number;
  69. showSender?: boolean;
  70. mode?: string;
  71. allowSendImages?: boolean;
  72. }
  73. const ENTER_KEY = "Enter";
  74. const indicWidth = 0.7;
  75. const avatarWidth = 24;
  76. const chatAvatarSx = { ...avatarSx, width: avatarWidth, height: avatarWidth };
  77. const avatarColSx = { width: 1.5 * avatarWidth, minWidth: 1.5 * avatarWidth, pt: 1 };
  78. const senderMsgSx = {
  79. width: "fit-content",
  80. maxWidth: "80%",
  81. } as SxProps<Theme>;
  82. const gridSx = { pb: "1em", mt: "unset", flex: 1, overflow: "auto" };
  83. const loadMoreSx = { width: "fit-content", marginLeft: "auto", marginRight: "auto" };
  84. const inputSx = { maxWidth: "unset" };
  85. const leftNameSx = { fontSize: "0.6em", fontWeight: "bolder", pl: `${indicWidth}em` };
  86. const rightNameSx: SxProps = {
  87. ...leftNameSx,
  88. pr: `${2 * indicWidth}em`,
  89. width: "100%",
  90. display: "flex",
  91. justifyContent: "flex-end",
  92. };
  93. const senderPaperSx = {
  94. pr: `${indicWidth}em`,
  95. pl: `${indicWidth}em`,
  96. mr: `${indicWidth}em`,
  97. position: "relative",
  98. "&:before": {
  99. content: "''",
  100. position: "absolute",
  101. width: "0",
  102. height: "0",
  103. borderTopWidth: `${indicWidth}em`,
  104. borderTopStyle: "solid",
  105. borderTopColor: (theme: Theme) => theme.palette.background.paper,
  106. borderLeft: `${indicWidth}em solid transparent`,
  107. borderRight: `${indicWidth}em solid transparent`,
  108. top: "0",
  109. right: `-${indicWidth}em`,
  110. },
  111. } as SxProps<Theme>;
  112. const otherPaperSx = {
  113. position: "relative",
  114. pl: `${indicWidth}em`,
  115. pr: `${indicWidth}em`,
  116. "&:before": {
  117. content: "''",
  118. position: "absolute",
  119. width: "0",
  120. height: "0",
  121. borderTopWidth: `${indicWidth}em`,
  122. borderTopStyle: "solid",
  123. borderTopColor: (theme: Theme) => theme.palette.background.paper,
  124. borderLeft: `${indicWidth}em solid transparent`,
  125. borderRight: `${indicWidth}em solid transparent`,
  126. top: "0",
  127. left: `-${indicWidth}em`,
  128. },
  129. } as SxProps<Theme>;
  130. const defaultBoxSx = {
  131. pl: `${indicWidth}em`,
  132. pr: `${indicWidth}em`,
  133. backgroundColor: (theme: Theme) =>
  134. theme.palette.mode == "dark"
  135. ? lighten(theme.palette.background.paper, 0.05)
  136. : darken(theme.palette.background.paper, 0.15),
  137. } as SxProps<Theme>;
  138. const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
  139. const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
  140. const imageSx = { width: 3 / 5, height: "auto" };
  141. interface key2Rows {
  142. key: string;
  143. }
  144. interface ChatRowProps {
  145. senderId: string;
  146. message: string;
  147. image?: string;
  148. name: string;
  149. className?: string;
  150. getAvatar: (id: string, sender: boolean) => ReactNode;
  151. index: number;
  152. showSender: boolean;
  153. mode: string;
  154. }
  155. const ChatRow = (props: ChatRowProps) => {
  156. const { senderId, message, image, name, className, getAvatar, index, showSender, mode } = props;
  157. const sender = senderId == name;
  158. const avatar = getAvatar(name, sender);
  159. return (
  160. <Grid
  161. container
  162. className={getSuffixedClassNames(className, sender ? "-sent" : "-received")}
  163. size={12}
  164. sx={noAnchorSx}
  165. justifyContent={sender ? "flex-end" : undefined}
  166. >
  167. <Grid sx={sender ? senderMsgSx : undefined}>
  168. {image ? (
  169. <Grid container justifyContent={sender ? "flex-end" : undefined}>
  170. <Box component="img" sx={imageSx} alt="Uploaded image" src={image} />
  171. </Grid>
  172. ) : null}
  173. {(!sender || showSender) && avatar ? (
  174. <Stack direction="row" gap={1} justifyContent={sender ? "flex-end" : undefined}>
  175. {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
  176. <Stack>
  177. <Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
  178. <Paper
  179. sx={sender ? senderPaperSx : otherPaperSx}
  180. data-idx={index}
  181. className={getSuffixedClassNames(className, "-" + mode)}
  182. >
  183. {mode == "pre" ? (
  184. <pre>{message}</pre>
  185. ) : mode == "raw" ? (
  186. message
  187. ) : (
  188. <Markdown>{message}</Markdown>
  189. )}
  190. </Paper>
  191. </Stack>
  192. {sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
  193. </Stack>
  194. ) : (
  195. <Paper
  196. sx={sender ? senderPaperSx : otherPaperSx}
  197. data-idx={index}
  198. className={getSuffixedClassNames(className, mode)}
  199. >
  200. {mode == "pre" ? (
  201. <pre>{message}</pre>
  202. ) : mode == "raw" ? (
  203. message
  204. ) : (
  205. <Markdown>{message}</Markdown>
  206. )}
  207. </Paper>
  208. )}
  209. </Grid>
  210. </Grid>
  211. );
  212. };
  213. const getChatKey = (start: number, page: number) => `Chat-${start}-${start + page}`;
  214. const Chat = (props: ChatProps) => {
  215. const {
  216. id,
  217. updateVarName,
  218. senderId = "taipy",
  219. onAction,
  220. withInput = true,
  221. defaultKey = "",
  222. maxFileSize = .8 * 1024 * 1024, // 0.8 MB
  223. pageSize = 50,
  224. showSender = false,
  225. allowSendImages = true,
  226. } = props;
  227. const dispatch = useDispatch();
  228. const module = useModule();
  229. const [rows, setRows] = useState<RowType[]>([]);
  230. const page = useRef<key2Rows>({ key: defaultKey });
  231. const [columns, setColumns] = useState<Array<string>>([]);
  232. const scrollDivRef = useRef<HTMLDivElement>(null);
  233. const anchorDivRef = useRef<HTMLElement>(null);
  234. const isAnchorDivVisible = useElementVisible(anchorDivRef);
  235. const [enableSend, setEnableSend] = useState(false);
  236. const [showMessage, setShowMessage] = useState(false);
  237. const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
  238. const [selectedFile, setSelectedFile] = useState<File | null>(null);
  239. const [imagePreview, setImagePreview] = useState<string | null>(null);
  240. const [objectURLs, setObjectURLs] = useState<string[]>([]);
  241. const fileInputRef = useRef<HTMLInputElement>(null);
  242. const userScrolled = useRef(false);
  243. const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
  244. const active = useDynamicProperty(props.active, props.defaultActive, true);
  245. const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
  246. const users = useLovListMemo(props.users, props.defaultUsers || "");
  247. const mode = useMemo(
  248. () => (["pre", "raw"].includes(props.mode || "") ? (props.mode as string) : "markdown"),
  249. [props.mode]
  250. );
  251. const boxSx = useMemo(
  252. () =>
  253. props.height
  254. ? ({
  255. ...defaultBoxSx,
  256. maxHeight: "" + Number(props.height) == "" + props.height ? props.height + "px" : props.height,
  257. display: "flex",
  258. flexDirection: "column",
  259. } as SxProps<Theme>)
  260. : defaultBoxSx,
  261. [props.height]
  262. );
  263. const onChangeHandler = useCallback((evt: ChangeEvent<HTMLInputElement>) => setEnableSend(!!evt.target.value), []);
  264. const sendAction = useCallback(
  265. (elt: HTMLInputElement | null | undefined, reason: string) => {
  266. if (elt && (elt?.value || imagePreview)) {
  267. toDataUrl(imagePreview)
  268. .then((dataUrl) => {
  269. dispatch(
  270. createSendActionNameAction(
  271. id,
  272. module,
  273. onAction,
  274. reason,
  275. updateVarName,
  276. elt?.value,
  277. senderId,
  278. dataUrl
  279. )
  280. );
  281. elt.value = "";
  282. setSelectedFile(null);
  283. setImagePreview((url) => {
  284. url && URL.revokeObjectURL(url);
  285. return null;
  286. });
  287. fileInputRef.current && (fileInputRef.current.value = "");
  288. })
  289. .catch(console.log);
  290. }
  291. },
  292. [imagePreview, updateVarName, onAction, senderId, id, dispatch, module]
  293. );
  294. const handleAction = useCallback(
  295. (evt: KeyboardEvent<HTMLDivElement>) => {
  296. if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
  297. sendAction(evt.currentTarget.querySelector("input"), evt.key);
  298. evt.preventDefault();
  299. }
  300. },
  301. [sendAction]
  302. );
  303. const handleClick = useCallback(
  304. (evt: MouseEvent<HTMLButtonElement>) => {
  305. sendAction(evt.currentTarget.parentElement?.parentElement?.querySelector("input"), "click");
  306. evt.preventDefault();
  307. },
  308. [sendAction]
  309. );
  310. const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
  311. const file = event.target.files ? event.target.files[0] : null;
  312. if (file) {
  313. if (file.type.startsWith("image/") && file.size <= maxFileSize) {
  314. setSelectedFile(file);
  315. const newImagePreview = URL.createObjectURL(file);
  316. setImagePreview(newImagePreview);
  317. setObjectURLs((prevURLs) => [...prevURLs, newImagePreview]);
  318. } else {
  319. dispatch(
  320. createNotificationAction({
  321. nType: "info",
  322. message:
  323. file.size > maxFileSize
  324. ? `Image size is limited to ${maxFileSize / 1024} KB`
  325. : "Only image file are authorized",
  326. system: false,
  327. duration: 3000,
  328. snackbarId: "Chat warning"
  329. })
  330. );
  331. setSelectedFile(null);
  332. setImagePreview(null);
  333. fileInputRef.current && (fileInputRef.current.value = "");
  334. }
  335. }
  336. }, [maxFileSize, dispatch]);
  337. const handleAttachClick = useCallback(() => fileInputRef.current && fileInputRef.current.click(), [fileInputRef]);
  338. const handleImageDelete = useCallback(() => {
  339. setSelectedFile(null);
  340. setImagePreview((url) => {
  341. url && URL.revokeObjectURL(url);
  342. return null;
  343. });
  344. fileInputRef.current && (fileInputRef.current.value = "");
  345. }, []);
  346. const avatars = useMemo(() => {
  347. return users.reduce((pv, elt) => {
  348. if (elt.id) {
  349. const id = elt.id.startsWith("<taipy.gui.icon.Icon") && typeof elt.item !== "string" ? elt.item.text : elt.id;
  350. pv[id] =
  351. typeof elt.item == "string" ? (
  352. <Tooltip title={elt.item}>
  353. <Avatar sx={chatAvatarSx}>{getInitials(elt.item)}</Avatar>
  354. </Tooltip>
  355. ) : (
  356. <IconAvatar img={elt.item} sx={chatAvatarSx} />
  357. );
  358. }
  359. return pv;
  360. }, {} as Record<string, React.ReactNode>);
  361. }, [users]);
  362. const getAvatar = useCallback(
  363. (id: string, sender: boolean) =>
  364. avatars[id] ||
  365. (sender ? null : (
  366. <Tooltip title={id}>
  367. <Avatar sx={chatAvatarSx}>{getInitials(id)}</Avatar>
  368. </Tooltip>
  369. )),
  370. [avatars]
  371. );
  372. const loadMoreItems = useCallback(
  373. (startIndex: number) => {
  374. const key = getChatKey(startIndex, pageSize);
  375. page.current = {
  376. key: key,
  377. };
  378. dispatch(
  379. createRequestInfiniteTableUpdateAction(
  380. updateVarName,
  381. id,
  382. module,
  383. [],
  384. key,
  385. startIndex,
  386. startIndex + pageSize,
  387. undefined,
  388. undefined,
  389. undefined,
  390. undefined,
  391. undefined,
  392. undefined,
  393. undefined,
  394. undefined,
  395. undefined,
  396. undefined,
  397. undefined,
  398. undefined,
  399. true // reverse
  400. )
  401. );
  402. },
  403. [pageSize, updateVarName, id, dispatch, module]
  404. );
  405. const showBottom = useCallback(() => {
  406. anchorDivRef.current?.scrollIntoView && anchorDivRef.current?.scrollIntoView();
  407. setShowMessage(false);
  408. }, []);
  409. const refresh = props.messages?.__taipy_refresh !== undefined;
  410. useEffect(() => {
  411. if (!refresh && props.messages && page.current.key && props.messages[page.current.key] !== undefined) {
  412. const newValue = props.messages[page.current.key];
  413. if (newValue.rowcount == 0) {
  414. setRows(emptyArray);
  415. } else {
  416. const nr = newValue.data as RowType[];
  417. if (Array.isArray(nr) && nr.length > newValue.start && nr[newValue.start]) {
  418. setRows((old) => {
  419. old.length && nr.length > old.length && setShowMessage(true);
  420. if (newValue.start > 0 && old.length > newValue.start) {
  421. return old.slice(0, newValue.start).concat(nr.slice(newValue.start));
  422. }
  423. return nr;
  424. });
  425. const cols = Object.keys(nr[newValue.start]);
  426. setColumns(cols.length > 2 ? cols : cols.length == 2 ? [...cols, ""] : ["", ...cols, "", ""]);
  427. }
  428. }
  429. page.current.key = getChatKey(0, pageSize);
  430. !userScrolled.current && showBottom();
  431. }
  432. }, [refresh, pageSize, props.messages, showBottom]);
  433. useEffect(() => {
  434. if (showMessage && !isAnchorDivVisible) {
  435. setAnchorPopup(scrollDivRef.current);
  436. setTimeout(() => setShowMessage(false), 5000);
  437. } else if (!showMessage) {
  438. setAnchorPopup(null);
  439. }
  440. }, [showMessage, isAnchorDivVisible]);
  441. useEffect(() => {
  442. if (refresh) {
  443. Promise.resolve().then(() => loadMoreItems(0)); // So that the state can be changed
  444. }
  445. }, [refresh, loadMoreItems]);
  446. useEffect(() => {
  447. loadMoreItems(0);
  448. }, [loadMoreItems]);
  449. useEffect(() => {
  450. return () => {
  451. for (const objectURL of objectURLs) {
  452. URL.revokeObjectURL(objectURL);
  453. }
  454. };
  455. }, [objectURLs]);
  456. const loadOlder = useCallback(
  457. (evt: MouseEvent<HTMLElement>) => {
  458. const { start } = evt.currentTarget.dataset;
  459. if (start) {
  460. loadMoreItems(parseInt(start));
  461. }
  462. },
  463. [loadMoreItems]
  464. );
  465. const handleOnScroll = useCallback((evt: UIEvent) => {
  466. userScrolled.current = (evt.target as HTMLDivElement).scrollHeight - (evt.target as HTMLDivElement).offsetHeight - (evt.target as HTMLDivElement).scrollTop > 1;
  467. }, []);
  468. return (
  469. <Tooltip title={hover || ""}>
  470. <Paper className={`${className} ${getComponentClassName(props.children)}`} sx={boxSx} id={id}>
  471. <Grid container rowSpacing={2} sx={gridSx} ref={scrollDivRef} onScroll={handleOnScroll}>
  472. {rows.length && !rows[0] ? (
  473. <Grid className={getSuffixedClassNames(className, "-load")} size={12} sx={noAnchorSx}>
  474. <Box sx={loadMoreSx}>
  475. <Button
  476. endIcon={<ArrowUpward />}
  477. onClick={loadOlder}
  478. data-start={rows.length - rows.findIndex((row) => !!row)}
  479. >
  480. Load More
  481. </Button>
  482. </Box>
  483. </Grid>
  484. ) : null}
  485. {rows.map((row, idx) =>
  486. row ? (
  487. <ChatRow
  488. key={columns[0] ? `${row[columns[0]]}` : `id${idx}`}
  489. senderId={senderId}
  490. message={`${row[columns[1]]}`}
  491. name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
  492. image={
  493. columns[3] && columns[3] != "_tp_index" && row[columns[3]]
  494. ? `${row[columns[3]]}`
  495. : undefined
  496. }
  497. className={className}
  498. getAvatar={getAvatar}
  499. index={idx}
  500. showSender={showSender}
  501. mode={mode}
  502. />
  503. ) : null
  504. )}
  505. <Box sx={anchorSx} ref={anchorDivRef} />
  506. </Grid>
  507. <Popper id={id} open={Boolean(anchorPopup)} anchorEl={anchorPopup} placement="right">
  508. <Chip
  509. label="A new message is available"
  510. variant="outlined"
  511. onClick={showBottom}
  512. icon={<ArrowDownward />}
  513. />
  514. </Popper>
  515. {withInput ? (
  516. <>
  517. {imagePreview && (
  518. <Box mb={1}>
  519. <Chip
  520. label={selectedFile?.name}
  521. avatar={<Avatar alt="Image preview" src={imagePreview} />}
  522. onDelete={handleImageDelete}
  523. variant="outlined"
  524. />
  525. </Box>
  526. )}
  527. <input
  528. type="file"
  529. ref={fileInputRef}
  530. style={noDisplayStyle}
  531. onChange={handleFileSelect}
  532. accept="image/*"
  533. />
  534. <TextField
  535. margin="dense"
  536. fullWidth
  537. onChange={onChangeHandler}
  538. className={getSuffixedClassNames(className, "-input")}
  539. label={`message (${senderId})`}
  540. disabled={!active}
  541. onKeyDown={handleAction}
  542. slotProps={{
  543. input: {
  544. startAdornment: allowSendImages ? (
  545. <InputAdornment position="start">
  546. <IconButton
  547. aria-label="upload image"
  548. onClick={handleAttachClick}
  549. edge="start"
  550. disabled={!active}
  551. >
  552. <AttachFile color={disableColor("primary", !active)} />
  553. </IconButton>
  554. </InputAdornment>
  555. ) : undefined,
  556. endAdornment: (
  557. <InputAdornment position="end">
  558. <IconButton
  559. aria-label="send message"
  560. onClick={handleClick}
  561. edge="end"
  562. disabled={!active || !(enableSend || imagePreview)}
  563. >
  564. <Send
  565. color={disableColor(
  566. "primary",
  567. !active || !(enableSend || imagePreview)
  568. )}
  569. />
  570. </IconButton>
  571. </InputAdornment>
  572. ),
  573. },
  574. }}
  575. sx={inputSx}
  576. />
  577. </>
  578. ) : null}
  579. {props.children}
  580. </Paper>
  581. </Tooltip>
  582. );
  583. };
  584. export default Chat;