Chat.tsx 16 KB

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