Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
N-Pex
8286c18020 Add day markers between articles 2024-10-09 15:09:24 +02:00
N-Pex
275b323765 Update package-lock.json 2024-10-09 14:28:45 +02:00
N-Pex
a6d3d1d8a8 Remove chat ui library and implement replacement 2024-10-09 14:24:00 +02:00
N-Pex
fa931f35b2 Add timestamps to ticket chat bubbles 2024-10-08 10:34:00 +02:00
7 changed files with 197 additions and 163 deletions

View file

@ -0,0 +1,20 @@
"use client";
import { FC } from "react";
import { Box, BoxProps } from "@mui/material";
interface MessageProps extends BoxProps {
message: string;
sentTime: string;
senderName: string;
direction: "outgoing" | "incoming";
}
export const Message: FC<MessageProps> = ({ message, sentTime, senderName, direction, ...props }) => {
return (
<Box {...props}>
<Box className="cs-message__time">{sentTime}{senderName ? ` - ${senderName}` : ''}</Box>
<Box className="cs-message__content" dangerouslySetInnerHTML={{ __html: message }} />
</Box>
)
};

View file

@ -0,0 +1,51 @@
"use client";
import { FC, useEffect, useRef, useCallback } from "react";
import { Box, styled, BoxProps } from "@mui/material";
interface MessageListProps extends BoxProps {
ticketId: string;
}
export const MessageList: FC<MessageListProps> = ({
ticketId,
children,
...props
}: MessageListProps) => {
const messageListRef = useRef<HTMLElement>(null);
useEffect(() => {
sessionStorage.removeItem("messageListScroll");
}, [ticketId]);
const onRefChange = useCallback(node => {
if (node !== null) {
// DOM node referenced by ref has changed and exists
const scrollTop = parseFloat(sessionStorage.getItem("messageListScroll") ?? "1000000");
node.scrollTop = Math.min(scrollTop, node.scrollHeight - node.clientHeight);
node.onscroll = () => {
sessionStorage.setItem("messageListScroll", node.scrollTop.toPrecision(6));
}
requestAnimationFrame(() => {
node.scrollTo({
top: node.scrollHeight - node.clientHeight,
behavior: "smooth",
});
});
}
}, []);
const InternalMessageList = styled((props: any) => (
<Box key="messageList" ref={onRefChange} {...props} />
))(({ theme }) => ({
overflowX: "hidden",
overflowY: "auto",
padding: "20px",
}));
return (
<InternalMessageList {...props}>
{children}
</InternalMessageList>
);
};

View file

@ -1,18 +1,27 @@
"use client"; "use client";
import { FC, useState, useEffect } from "react"; import { FC, useState, useEffect, useRef, memo } from "react";
import { getTicketAction, getTicketArticlesAction } from "app/_actions/tickets"; import { getTicketAction, getTicketArticlesAction } from "app/_actions/tickets";
import { Grid, Box, Typography } from "@mui/material"; import { Grid, Box, Typography, styled, BoxProps } from "@mui/material";
import { Button, fonts, colors } from "@link-stack/ui"; import { Button, fonts, colors } from "@link-stack/ui";
import "@chatscope/chat-ui-kit-styles/dist/default/styles.min.css";
import {
MainContainer,
ChatContainer,
MessageList,
Message,
ConversationHeader,
} from "@chatscope/chat-ui-kit-react";
import { ArticleCreateDialog } from "./ArticleCreateDialog"; import { ArticleCreateDialog } from "./ArticleCreateDialog";
import { Message } from "./Message";
import { MessageList } from "./MessageList";
const ChatContainer = styled((props: BoxProps) => <Box {...props} />)(
({ theme }) => ({
overflow: "hidden",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
}),
);
const ChatHeader = styled((props: any) => <Box {...props} />)(({ theme }) => ({
padding: "20px",
textAlign: "center",
}));
interface TicketDetailProps { interface TicketDetailProps {
id: string; id: string;
@ -26,9 +35,22 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [articleKind, setArticleKind] = useState("note"); const [articleKind, setArticleKind] = useState("note");
const messageListRef = useRef<HTMLElement>(null);
useEffect(() => {
// Reset remembered state
sessionStorage.setItem("ticketDetailTicket", "");
sessionStorage.setItem("ticketDetailArticles", "");
}, [id])
useEffect(() => { useEffect(() => {
const fetchTicket = async () => { const fetchTicket = async () => {
const result = await getTicketAction(id); const result = await getTicketAction(id);
const resultRaw = JSON.stringify(result);
if (resultRaw == sessionStorage.getItem("ticketDetailTicket")) {
return; // No change
}
sessionStorage.setItem("ticketDetailTicket", resultRaw);
setTicket(result); setTicket(result);
}; };
@ -42,6 +64,11 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
useEffect(() => { useEffect(() => {
const fetchTicketArticles = async () => { const fetchTicketArticles = async () => {
const result = await getTicketArticlesAction(id); const result = await getTicketArticlesAction(id);
const resultRaw = JSON.stringify(result);
if (resultRaw == sessionStorage.getItem("ticketDetailArticles")) {
return; // No change
}
sessionStorage.setItem("ticketDetailArticles", resultRaw);
setTicketArticles(result); setTicketArticles(result);
}; };
@ -64,17 +91,8 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
<Box sx={{ height: "100%", width: "100%", background: veryLightGray }}> <Box sx={{ height: "100%", width: "100%", background: veryLightGray }}>
{shouldRender && ( {shouldRender && (
<> <>
<MainContainer> <ChatContainer className="cs-main-container">
<ChatContainer> <ChatHeader className="cs-conversation-header">
<ConversationHeader>
<ConversationHeader.Content>
<Box
sx={{
width: "100%",
textAlign: "center",
fontWeight: "bold",
}}
>
<Typography <Typography
variant="h5" variant="h5"
sx={{ sx={{
@ -93,11 +111,14 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
>{`Ticket #${ticket.number} (created ${new Date( >{`Ticket #${ticket.number} (created ${new Date(
ticket.createdAt, ticket.createdAt,
).toLocaleDateString()})`}</Typography> ).toLocaleDateString()})`}</Typography>
</Box> </ChatHeader>
</ConversationHeader.Content> <MessageList ticketId={id}>
</ConversationHeader> {ticketArticles.edges.map(({ node: article }: any, index: number) => {
<MessageList style={{ marginBottom: 80 }}> const thisDate = new Date(ticketArticles.edges[index].node.updatedAt).toLocaleDateString();
{ticketArticles.edges.map(({ node: article }: any) => ( const lastDate = index > 0 ? new Date(ticketArticles.edges[index - 1].node.updatedAt).toLocaleDateString() : ""
return (
<>
{thisDate !== lastDate ? (<Box className="cs-day-marker"><Box className="cs-day-marker__line"></Box><Box className="cs-day-marker__text" sx={{background: veryLightGray}}>{thisDate}</Box></Box>) : (null)}
<Message <Message
key={article.id} key={article.id}
className={ className={
@ -107,26 +128,21 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
? "outgoing-message" ? "outgoing-message"
: "incoming-message" : "incoming-message"
} }
model={{ message={article.bodyWithUrls}
message: article.bodyWithUrls, sentTime={new Date(article.updatedAt).toLocaleString()}
sentTime: article.updated_at, senderName={article.sender?.name}
sender: article.from, direction={
direction: article.sender === "Agent" ? "outgoing" : "incoming"
article.sender === "Agent" ? "outgoing" : "incoming", }
position: "single",
}}
/> />
))} </>
)})}
</MessageList> </MessageList>
</ChatContainer>
<Box <Box
sx={{ sx={{
height: 80, flex: "0 0 80",
background: veryLightGray, background: veryLightGray,
borderTop: `1px solid ${lightGray}`, borderTop: `1px solid ${lightGray}`,
position: "absolute",
bottom: 0,
width: "100%",
zIndex: 1000, zIndex: 1000,
}} }}
> >
@ -160,7 +176,7 @@ export const TicketDetail: FC<TicketDetailProps> = ({ id }) => {
</Grid> </Grid>
</Grid> </Grid>
</Box> </Box>
</MainContainer> </ChatContainer>
<ArticleCreateDialog <ArticleCreateDialog
ticketID={ticket.internalId} ticketID={ticket.internalId}
open={dialogOpen} open={dialogOpen}

View file

@ -19,6 +19,7 @@ query getTicketArticles($ticketId: ID!) {
emailAddress emailAddress
} }
} }
updatedAt
} }
} }
} }

View file

@ -4,6 +4,15 @@ body {
text-size-adjust: none; text-size-adjust: none;
} }
.cs-message__time {
color: #888;
font-family: Roboto;
font-size: 12px;
position: relative;
left: 12px;
top: 12px;
}
.internal-note .cs-message__content { .internal-note .cs-message__content {
background-color: #FFB62088 !important; background-color: #FFB62088 !important;
border: 2px solid #FFB620 !important; border: 2px solid #FFB620 !important;
@ -75,8 +84,6 @@ body {
.cs-conversation-header { .cs-conversation-header {
background-color: #ddd !important; background-color: #ddd !important;
border: 0 !important;
padding: 20px !important;
border-bottom: 1px solid #ccc !important; border-bottom: 1px solid #ccc !important;
} }
@ -89,6 +96,34 @@ body {
} }
.cs-main-container { .cs-main-container {
border: 0 !important; border-right: 1px solid #ccc;
border-right: 1px solid #ccc !important; }
.cs-day-marker {
width: 100%;
text-align: center;
height: 30px;
color: #aaa;
font-family: Roboto;
font-size: 14px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.cs-day-marker__line {
position: absolute;
content: " ";
height: 1px;
top: 15px;
left: 10px;
right: 10px;
background-color: #aaa;
}
.cs-day-marker__text {
position: relative;
width: fit-content;
padding: 10px;
} }

View file

@ -10,8 +10,6 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@chatscope/chat-ui-kit-react": "^2.0.3",
"@chatscope/chat-ui-kit-styles": "^1.4.0",
"@emotion/cache": "^11.13.1", "@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",

87
package-lock.json generated
View file

@ -234,8 +234,6 @@
"name": "@link-stack/link", "name": "@link-stack/link",
"version": "2.2.0", "version": "2.2.0",
"dependencies": { "dependencies": {
"@chatscope/chat-ui-kit-react": "^2.0.3",
"@chatscope/chat-ui-kit-styles": "^1.4.0",
"@emotion/cache": "^11.13.1", "@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",
@ -1001,32 +999,6 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@chatscope/chat-ui-kit-react": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-react/-/chat-ui-kit-react-2.0.3.tgz",
"integrity": "sha512-0IkjFskRec7SHrFivOQPiZMie5GLQL+ZnROiIbj4yptbC3aMEMFdHRAZrfqlid3uQx9kYhdtn34wMLh1vVNMLA==",
"license": "MIT",
"dependencies": {
"@chatscope/chat-ui-kit-styles": "^1.2.0",
"@fortawesome/fontawesome-free": "^5.12.1",
"@fortawesome/fontawesome-svg-core": "^1.2.26",
"@fortawesome/free-solid-svg-icons": "^5.12.0",
"@fortawesome/react-fontawesome": "^0.1.8",
"classnames": "^2.2.6",
"prop-types": "^15.7.2"
},
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.12.0 || ^17.0.0 || ^18.2.0",
"react-dom": "^16.12.0 || ^17.0.0 || ^18.2.0"
}
},
"node_modules/@chatscope/chat-ui-kit-styles": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@chatscope/chat-ui-kit-styles/-/chat-ui-kit-styles-1.4.0.tgz",
"integrity": "sha512-016mBJD3DESw7Nh+lkKcPd22xG92ghA0VpIXIbjQtmXhC7Ve6wRazTy8z1Ahut+Tbv179+JxrftuMngsj/yV8Q==",
"license": "MIT"
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz",
@ -1655,65 +1627,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
"integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"hasInstallScript": true,
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.x"
}
},
"node_modules/@graphile/logger": { "node_modules/@graphile/logger": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@graphile/logger/-/logger-0.2.0.tgz",