Chat.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. /*
  2. * Copyright 2021-2024 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. UIEvent,
  24. } from "react";
  25. import { SxProps, Theme, darken, lighten } from "@mui/material/styles";
  26. import Avatar from "@mui/material/Avatar";
  27. import Box from "@mui/material/Box";
  28. import Button from "@mui/material/Button";
  29. import Chip from "@mui/material/Chip";
  30. import Grid from "@mui/material/Grid2";
  31. import IconButton from "@mui/material/IconButton";
  32. import InputAdornment from "@mui/material/InputAdornment";
  33. import Paper from "@mui/material/Paper";
  34. import Popper from "@mui/material/Popper";
  35. import TextField from "@mui/material/TextField";
  36. import Tooltip from "@mui/material/Tooltip";
  37. import Send from "@mui/icons-material/Send";
  38. import ArrowDownward from "@mui/icons-material/ArrowDownward";
  39. import ArrowUpward from "@mui/icons-material/ArrowUpward";
  40. import { createRequestInfiniteTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
  41. import { TaipyActiveProps, disableColor, getSuffixedClassNames } from "./utils";
  42. import { useClassNames, useDispatch, useDynamicProperty, useElementVisible, useModule } from "../../utils/hooks";
  43. import { LoVElt, useLovListMemo } from "./lovUtils";
  44. import { IconAvatar, avatarSx } from "../../utils/icon";
  45. import { emptyArray, getInitials } from "../../utils";
  46. import { RowType, TableValueType } from "./tableUtils";
  47. import { Stack } from "@mui/material";
  48. const Markdown = lazy(() => import("react-markdown"));
  49. interface ChatProps extends TaipyActiveProps {
  50. messages?: TableValueType;
  51. withInput?: boolean;
  52. users?: LoVElt[];
  53. defaultUsers?: string;
  54. onAction?: string;
  55. senderId?: string;
  56. height?: string;
  57. defaultKey?: string; // for testing purposes only
  58. pageSize?: number;
  59. showSender?: boolean;
  60. mode?: string;
  61. }
  62. const ENTER_KEY = "Enter";
  63. const indicWidth = 0.7;
  64. const avatarWidth = 24;
  65. const chatAvatarSx = { ...avatarSx, width: avatarWidth, height: avatarWidth };
  66. const avatarColSx = { width: 1.5 * avatarWidth, minWidth: 1.5 * avatarWidth, pt: 1 };
  67. const senderMsgSx = {
  68. width: "fit-content",
  69. maxWidth: "80%",
  70. } as SxProps<Theme>;
  71. const gridSx = { pb: "1em", mt: "unset", flex: 1, overflow: "auto" };
  72. const loadMoreSx = { width: "fit-content", marginLeft: "auto", marginRight: "auto" };
  73. const inputSx = { maxWidth: "unset" };
  74. const leftNameSx = { fontSize: "0.6em", fontWeight: "bolder", pl: `${indicWidth}em` };
  75. const rightNameSx: SxProps = {
  76. ...leftNameSx,
  77. pr: `${2 * indicWidth}em`,
  78. width: "100%",
  79. display: "flex",
  80. justifyContent: "flex-end",
  81. };
  82. const senderPaperSx = {
  83. pr: `${indicWidth}em`,
  84. pl: `${indicWidth}em`,
  85. mr: `${indicWidth}em`,
  86. position: "relative",
  87. "&:before": {
  88. content: "''",
  89. position: "absolute",
  90. width: "0",
  91. height: "0",
  92. borderTopWidth: `${indicWidth}em`,
  93. borderTopStyle: "solid",
  94. borderTopColor: (theme: Theme) => theme.palette.background.paper,
  95. borderLeft: `${indicWidth}em solid transparent`,
  96. borderRight: `${indicWidth}em solid transparent`,
  97. top: "0",
  98. right: `-${indicWidth}em`,
  99. },
  100. } as SxProps<Theme>;
  101. const otherPaperSx = {
  102. position: "relative",
  103. pl: `${indicWidth}em`,
  104. pr: `${indicWidth}em`,
  105. "&:before": {
  106. content: "''",
  107. position: "absolute",
  108. width: "0",
  109. height: "0",
  110. borderTopWidth: `${indicWidth}em`,
  111. borderTopStyle: "solid",
  112. borderTopColor: (theme: Theme) => theme.palette.background.paper,
  113. borderLeft: `${indicWidth}em solid transparent`,
  114. borderRight: `${indicWidth}em solid transparent`,
  115. top: "0",
  116. left: `-${indicWidth}em`,
  117. },
  118. } as SxProps<Theme>;
  119. const defaultBoxSx = {
  120. pl: `${indicWidth}em`,
  121. pr: `${indicWidth}em`,
  122. backgroundColor: (theme: Theme) =>
  123. theme.palette.mode == "dark"
  124. ? lighten(theme.palette.background.paper, 0.05)
  125. : darken(theme.palette.background.paper, 0.15),
  126. } as SxProps<Theme>;
  127. const noAnchorSx = { overflowAnchor: "none", "& *": { overflowAnchor: "none" } } as SxProps<Theme>;
  128. const anchorSx = { overflowAnchor: "auto", height: "1px", width: "100%" } as SxProps<Theme>;
  129. interface key2Rows {
  130. key: string;
  131. }
  132. interface ChatRowProps {
  133. senderId: string;
  134. message: string;
  135. name: string;
  136. className?: string;
  137. getAvatar: (id: string, sender: boolean) => ReactNode;
  138. index: number;
  139. showSender: boolean;
  140. mode: string;
  141. }
  142. const ChatRow = (props: ChatRowProps) => {
  143. const { senderId, message, name, className, getAvatar, index, showSender, mode } = props;
  144. const sender = senderId == name;
  145. const avatar = getAvatar(name, sender);
  146. return (
  147. <Grid
  148. container
  149. className={getSuffixedClassNames(className, sender ? "-sent" : "-received")}
  150. size={12}
  151. sx={noAnchorSx}
  152. justifyContent={sender ? "flex-end" : undefined}
  153. >
  154. <Grid sx={sender ? senderMsgSx : undefined}>
  155. {(!sender || showSender) && avatar ? (
  156. <Stack direction="row" gap={1}>
  157. {!sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
  158. <Stack>
  159. <Box sx={sender ? rightNameSx : leftNameSx}>{name}</Box>
  160. <Paper
  161. sx={sender ? senderPaperSx : otherPaperSx}
  162. data-idx={index}
  163. className={getSuffixedClassNames(className, "-" + mode)}
  164. >
  165. {mode == "pre" ? (
  166. <pre>{message}</pre>
  167. ) : mode == "raw" ? (
  168. message
  169. ) : (
  170. <Markdown>{message}</Markdown>
  171. )}
  172. </Paper>
  173. </Stack>
  174. {sender ? <Box sx={avatarColSx}>{avatar}</Box> : null}
  175. </Stack>
  176. ) : (
  177. <Paper
  178. sx={sender ? senderPaperSx : otherPaperSx}
  179. data-idx={index}
  180. className={getSuffixedClassNames(className, mode)}
  181. >
  182. {mode == "pre" ? (
  183. <pre>{message}</pre>
  184. ) : mode == "raw" ? (
  185. message
  186. ) : (
  187. <Markdown>{message}</Markdown>
  188. )}
  189. </Paper>
  190. )}
  191. </Grid>
  192. </Grid>
  193. );
  194. };
  195. const getChatKey = (start: number, page: number) => `Chat-${start}-${start + page}`;
  196. const Chat = (props: ChatProps) => {
  197. const {
  198. id,
  199. updateVarName,
  200. senderId = "taipy",
  201. onAction,
  202. withInput = true,
  203. defaultKey = "",
  204. pageSize = 50,
  205. showSender = false,
  206. } = props;
  207. const dispatch = useDispatch();
  208. const module = useModule();
  209. const [rows, setRows] = useState<RowType[]>([]);
  210. const page = useRef<key2Rows>({ key: defaultKey });
  211. const [columns, setColumns] = useState<Array<string>>([]);
  212. const scrollDivRef = useRef<HTMLDivElement>(null);
  213. const anchorDivRef = useRef<HTMLElement>(null);
  214. const isAnchorDivVisible = useElementVisible(anchorDivRef);
  215. const [showMessage, setShowMessage] = useState(false);
  216. const [anchorPopup, setAnchorPopup] = useState<HTMLDivElement | null>(null);
  217. const userScrolled = useRef(false);
  218. const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
  219. const active = useDynamicProperty(props.active, props.defaultActive, true);
  220. const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
  221. const users = useLovListMemo(props.users, props.defaultUsers || "");
  222. const mode = useMemo(
  223. () => (["pre", "raw"].includes(props.mode || "") ? (props.mode as string) : "markdown"),
  224. [props.mode]
  225. );
  226. const boxSx = useMemo(
  227. () =>
  228. props.height
  229. ? ({
  230. ...defaultBoxSx,
  231. maxHeight: "" + Number(props.height) == "" + props.height ? props.height + "px" : props.height,
  232. display: "flex",
  233. flexDirection: "column",
  234. } as SxProps<Theme>)
  235. : defaultBoxSx,
  236. [props.height]
  237. );
  238. const handleAction = useCallback(
  239. (evt: KeyboardEvent<HTMLDivElement>) => {
  240. if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && ENTER_KEY == evt.key) {
  241. const elt = evt.currentTarget.querySelector("input");
  242. if (elt?.value) {
  243. dispatch(
  244. createSendActionNameAction(id, module, onAction, evt.key, updateVarName, elt?.value, senderId)
  245. );
  246. elt.value = "";
  247. }
  248. evt.preventDefault();
  249. }
  250. },
  251. [updateVarName, onAction, senderId, id, dispatch, module]
  252. );
  253. const handleClick = useCallback(
  254. (evt: MouseEvent<HTMLButtonElement>) => {
  255. const elt = evt.currentTarget.parentElement?.parentElement?.querySelector("input");
  256. if (elt?.value) {
  257. dispatch(
  258. createSendActionNameAction(id, module, onAction, "click", updateVarName, elt?.value, senderId)
  259. );
  260. elt.value = "";
  261. }
  262. evt.preventDefault();
  263. },
  264. [updateVarName, onAction, senderId, id, dispatch, module]
  265. );
  266. const avatars = useMemo(() => {
  267. return users.reduce((pv, elt) => {
  268. if (elt.id) {
  269. pv[elt.id] =
  270. typeof elt.item == "string" ? (
  271. <Tooltip title={elt.item}>
  272. <Avatar sx={chatAvatarSx}>{getInitials(elt.item)}</Avatar>
  273. </Tooltip>
  274. ) : (
  275. <IconAvatar img={elt.item} sx={chatAvatarSx} />
  276. );
  277. }
  278. return pv;
  279. }, {} as Record<string, React.ReactNode>);
  280. }, [users]);
  281. const getAvatar = useCallback(
  282. (id: string, sender: boolean) =>
  283. avatars[id] ||
  284. (sender ? null : (
  285. <Tooltip title={id}>
  286. <Avatar sx={chatAvatarSx}>{getInitials(id)}</Avatar>
  287. </Tooltip>
  288. )),
  289. [avatars]
  290. );
  291. const loadMoreItems = useCallback(
  292. (startIndex: number) => {
  293. const key = getChatKey(startIndex, pageSize);
  294. page.current = {
  295. key: key,
  296. };
  297. dispatch(
  298. createRequestInfiniteTableUpdateAction(
  299. updateVarName,
  300. id,
  301. module,
  302. [],
  303. key,
  304. startIndex,
  305. startIndex + pageSize,
  306. undefined,
  307. undefined,
  308. undefined,
  309. undefined,
  310. undefined,
  311. undefined,
  312. undefined,
  313. undefined,
  314. undefined,
  315. undefined,
  316. undefined,
  317. undefined,
  318. true // reverse
  319. )
  320. );
  321. },
  322. [pageSize, updateVarName, id, dispatch, module]
  323. );
  324. const showBottom = useCallback(() => {
  325. anchorDivRef.current?.scrollIntoView && anchorDivRef.current?.scrollIntoView();
  326. setShowMessage(false);
  327. }, []);
  328. const refresh = props.messages?.__taipy_refresh !== undefined;
  329. useEffect(() => {
  330. if (!refresh && props.messages && page.current.key && props.messages[page.current.key] !== undefined) {
  331. const newValue = props.messages[page.current.key];
  332. if (newValue.rowcount == 0) {
  333. setRows(emptyArray);
  334. } else {
  335. const nr = newValue.data as RowType[];
  336. if (Array.isArray(nr) && nr.length > newValue.start && nr[newValue.start]) {
  337. setRows((old) => {
  338. old.length && nr.length > old.length && setShowMessage(true);
  339. if (newValue.start > 0 && old.length > newValue.start) {
  340. return old.slice(0, newValue.start).concat(nr.slice(newValue.start));
  341. }
  342. return nr;
  343. });
  344. const cols = Object.keys(nr[newValue.start]);
  345. setColumns(cols.length > 2 ? cols : cols.length == 2 ? [...cols, ""] : ["", ...cols, "", ""]);
  346. }
  347. }
  348. page.current.key = getChatKey(0, pageSize);
  349. !userScrolled.current && showBottom();
  350. }
  351. }, [refresh, pageSize, props.messages, showBottom]);
  352. useEffect(() => {
  353. if (showMessage && !isAnchorDivVisible) {
  354. setAnchorPopup(scrollDivRef.current);
  355. setTimeout(() => setShowMessage(false), 5000);
  356. } else if (!showMessage) {
  357. setAnchorPopup(null);
  358. }
  359. }, [showMessage, isAnchorDivVisible]);
  360. useEffect(() => {
  361. if (refresh) {
  362. Promise.resolve().then(() => loadMoreItems(0)); // So that the state can be changed
  363. }
  364. }, [refresh, loadMoreItems]);
  365. useEffect(() => {
  366. loadMoreItems(0);
  367. }, [loadMoreItems]);
  368. const loadOlder = useCallback(
  369. (evt: MouseEvent<HTMLElement>) => {
  370. const { start } = evt.currentTarget.dataset;
  371. if (start) {
  372. loadMoreItems(parseInt(start));
  373. }
  374. },
  375. [loadMoreItems]
  376. );
  377. const handleOnScroll = useCallback((evt: UIEvent) => {
  378. userScrolled.current = (evt.target as HTMLDivElement).scrollHeight - (evt.target as HTMLDivElement).offsetHeight - (evt.target as HTMLDivElement).scrollTop > 1;
  379. }, []);
  380. return (
  381. <Tooltip title={hover || ""}>
  382. <Paper className={className} sx={boxSx} id={id}>
  383. <Grid container rowSpacing={2} sx={gridSx} ref={scrollDivRef} onScroll={handleOnScroll}>
  384. {rows.length && !rows[0] ? (
  385. <Grid className={getSuffixedClassNames(className, "-load")} size={12} sx={noAnchorSx}>
  386. <Box sx={loadMoreSx}>
  387. <Button
  388. endIcon={<ArrowUpward />}
  389. onClick={loadOlder}
  390. data-start={rows.length - rows.findIndex((row) => !!row)}
  391. >
  392. Load More
  393. </Button>
  394. </Box>
  395. </Grid>
  396. ) : null}
  397. {rows.map((row, idx) =>
  398. row ? (
  399. <ChatRow
  400. key={columns[0] ? `${row[columns[0]]}` : `id${idx}`}
  401. senderId={senderId}
  402. message={`${row[columns[1]]}`}
  403. name={columns[2] ? `${row[columns[2]]}` : "Unknown"}
  404. className={className}
  405. getAvatar={getAvatar}
  406. index={idx}
  407. showSender={showSender}
  408. mode={mode}
  409. />
  410. ) : null
  411. )}
  412. <Box sx={anchorSx} ref={anchorDivRef} />
  413. </Grid>
  414. <Popper id={id} open={Boolean(anchorPopup)} anchorEl={anchorPopup} placement="right">
  415. <Chip
  416. label="A new message is available"
  417. variant="outlined"
  418. onClick={showBottom}
  419. icon={<ArrowDownward />}
  420. />
  421. </Popper>
  422. {withInput ? (
  423. <TextField
  424. margin="dense"
  425. fullWidth
  426. className={getSuffixedClassNames(className, "-input")}
  427. label={`message (${senderId})`}
  428. disabled={!active}
  429. onKeyDown={handleAction}
  430. slotProps={{
  431. input: {
  432. endAdornment: (
  433. <InputAdornment position="end">
  434. <IconButton
  435. aria-label="send message"
  436. onClick={handleClick}
  437. edge="end"
  438. disabled={!active}
  439. >
  440. <Send color={disableColor("primary", !active)} />
  441. </IconButton>
  442. </InputAdornment>
  443. ),
  444. },
  445. }}
  446. sx={inputSx}
  447. />
  448. ) : null}
  449. </Paper>
  450. </Tooltip>
  451. );
  452. };
  453. export default Chat;