feat: start replying messages on frontend

This commit is contained in:
canove
2020-10-28 20:26:39 -03:00
parent de86208a36
commit b47c1a83b4
5 changed files with 253 additions and 118 deletions

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useContext } from "react";
import "emoji-mart/css/emoji-mart.css"; import "emoji-mart/css/emoji-mart.css";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Picker } from "emoji-mart"; import { Picker } from "emoji-mart";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import MicRecorder from "mic-recorder-to-mp3"; import MicRecorder from "mic-recorder-to-mp3";
import clsx from "clsx";
import { makeStyles } from "@material-ui/core/styles"; import { makeStyles } from "@material-ui/core/styles";
import Paper from "@material-ui/core/Paper"; import Paper from "@material-ui/core/Paper";
@@ -15,6 +16,7 @@ import IconButton from "@material-ui/core/IconButton";
import MoodIcon from "@material-ui/icons/Mood"; import MoodIcon from "@material-ui/icons/Mood";
import SendIcon from "@material-ui/icons/Send"; import SendIcon from "@material-ui/icons/Send";
import CancelIcon from "@material-ui/icons/Cancel"; import CancelIcon from "@material-ui/icons/Cancel";
import ClearIcon from "@material-ui/icons/Clear";
import MicIcon from "@material-ui/icons/Mic"; import MicIcon from "@material-ui/icons/Mic";
import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline"; import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline";
import HighlightOffIcon from "@material-ui/icons/HighlightOff"; import HighlightOffIcon from "@material-ui/icons/HighlightOff";
@@ -22,20 +24,30 @@ import HighlightOffIcon from "@material-ui/icons/HighlightOff";
import { i18n } from "../../translate/i18n"; import { i18n } from "../../translate/i18n";
import api from "../../services/api"; import api from "../../services/api";
import RecordingTimer from "./RecordingTimer"; import RecordingTimer from "./RecordingTimer";
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
const Mp3Recorder = new MicRecorder({ bitRate: 128 }); const Mp3Recorder = new MicRecorder({ bitRate: 128 });
const useStyles = makeStyles(theme => ({ const useStyles = makeStyles(theme => ({
newMessageBox: { mainWrapper: {
background: "#eee", background: "#eee",
display: "flex", display: "flex",
padding: "7px", flexDirection: "column",
alignItems: "center", alignItems: "center",
borderTop: "1px solid rgba(0, 0, 0, 0.12)", borderTop: "1px solid rgba(0, 0, 0, 0.12)",
}, },
newMessageBox: {
background: "#eee",
width: "100%",
display: "flex",
padding: "7px",
alignItems: "center",
},
messageInputWrapper: { messageInputWrapper: {
padding: 6, padding: 6,
marginRight: 7,
background: "#fff", background: "#fff",
display: "flex", display: "flex",
borderRadius: 20, borderRadius: 20,
@@ -79,8 +91,6 @@ const useStyles = makeStyles(theme => ({
position: "absolute", position: "absolute",
top: "20%", top: "20%",
left: "50%", left: "50%",
// marginTop: 8,
// marginBottom: 6,
marginLeft: -12, marginLeft: -12,
}, },
@@ -102,6 +112,62 @@ const useStyles = makeStyles(theme => ({
sendAudioIcon: { sendAudioIcon: {
color: "green", color: "green",
}, },
quotedMsgWrapper: {
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "center",
paddingTop: 8,
paddingLeft: 73,
paddingRight: 7,
},
quotedContainerRight: {
flex: 1,
marginRight: 5,
overflowY: "hidden",
backgroundColor: "rgba(0, 0, 0, 0.05)",
borderRadius: "7.5px",
display: "flex",
position: "relative",
},
quotedMsgRight: {
padding: 10,
maxWidth: 300,
height: "auto",
whiteSpace: "pre-wrap",
},
quotedSideRight: {
flex: "none",
width: "4px",
backgroundColor: "#35cd96",
},
quotedContainerLeft: {
overflow: "hidden",
backgroundColor: "rgba(0, 0, 0, 0.05)",
borderRadius: "7.5px",
display: "flex",
position: "relative",
},
quotedMsg: {
padding: 10,
maxWidth: 300,
height: "auto",
display: "block",
whiteSpace: "pre-wrap",
overflow: "hidden",
},
quotedSideLeft: {
flex: "none",
width: "4px",
backgroundColor: "#6bcbef",
},
})); }));
const MessageInput = ({ ticketStatus }) => { const MessageInput = ({ ticketStatus }) => {
@@ -114,6 +180,9 @@ const MessageInput = ({ ticketStatus }) => {
const [showEmoji, setShowEmoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const { setReplyingMessage, replyingMessage } = useContext(
ReplyMessageContext
);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -263,6 +332,40 @@ const MessageInput = ({ ticketStatus }) => {
} }
}; };
const renderQuotedMessage = message => {
return (
<div className={classes.quotedMsgWrapper}>
<div
className={clsx(classes.quotedContainerLeft, {
[classes.quotedContainerRight]: message.fromMe,
})}
>
<span
className={clsx(classes.quotedSideLeft, {
[classes.quotedSideRight]: message.quotedMsg?.fromMe,
})}
></span>
<div className={classes.quotedMsg}>
{!message.fromMe && (
<span className={classes.messageContactName}>
{message.contact?.name}
</span>
)}
{message.body}
</div>
</div>
<IconButton
aria-label="showRecorder"
component="span"
disabled={loading || ticketStatus !== "open"}
onClick={() => setReplyingMessage({})}
>
<ClearIcon className={classes.sendMessageIcons} />
</IconButton>
</div>
);
};
if (medias.length > 0) if (medias.length > 0)
return ( return (
<Paper elevation={0} square className={classes.viewMediaInputWrapper}> <Paper elevation={0} square className={classes.viewMediaInputWrapper}>
@@ -296,115 +399,118 @@ const MessageInput = ({ ticketStatus }) => {
); );
else { else {
return ( return (
<Paper square elevation={0} className={classes.newMessageBox}> <Paper square elevation={0} className={classes.mainWrapper}>
<IconButton {replyingMessage.id && renderQuotedMessage(replyingMessage)}
aria-label="emojiPicker" <div className={classes.newMessageBox}>
component="span"
disabled={loading || recording || ticketStatus !== "open"}
onClick={e => setShowEmoji(prevState => !prevState)}
>
<MoodIcon className={classes.sendMessageIcons} />
</IconButton>
{showEmoji ? (
<div className={classes.emojiBox}>
<Picker
perLine={16}
showPreview={false}
showSkinTones={false}
onSelect={handleAddEmoji}
/>
</div>
) : null}
<input
multiple
type="file"
id="upload-button"
disabled={loading || recording || ticketStatus !== "open"}
className={classes.uploadInput}
onChange={handleChangeMedias}
/>
<label htmlFor="upload-button">
<IconButton <IconButton
aria-label="upload" aria-label="emojiPicker"
component="span" component="span"
disabled={loading || recording || ticketStatus !== "open"} disabled={loading || recording || ticketStatus !== "open"}
onClick={e => setShowEmoji(prevState => !prevState)}
> >
<AttachFileIcon className={classes.sendMessageIcons} /> <MoodIcon className={classes.sendMessageIcons} />
</IconButton> </IconButton>
</label> {showEmoji ? (
<div className={classes.messageInputWrapper}> <div className={classes.emojiBox}>
<InputBase <Picker
inputRef={input => input && input.focus()} perLine={16}
className={classes.messageInput} showPreview={false}
placeholder={ showSkinTones={false}
ticketStatus === "open" onSelect={handleAddEmoji}
? i18n.t("messagesInput.placeholderOpen") />
: i18n.t("messagesInput.placeholderClosed") </div>
} ) : null}
multiline
rowsMax={5}
value={inputMessage}
onChange={handleChangeInput}
disabled={recording || loading || ticketStatus !== "open"}
onPaste={e => {
ticketStatus === "open" && handleInputPaste(e);
}}
onKeyPress={e => {
if (loading || e.shiftKey) return;
else if (e.key === "Enter") {
handleSendMessage();
}
}}
/>
</div>
{inputMessage ? (
<IconButton
aria-label="sendMessage"
component="span"
onClick={handleSendMessage}
disabled={loading}
>
<SendIcon className={classes.sendMessageIcons} />
</IconButton>
) : recording ? (
<div className={classes.recorderWrapper}>
<IconButton
aria-label="cancelRecording"
component="span"
fontSize="large"
disabled={loading}
onClick={handleCancelAudio}
>
<HighlightOffIcon className={classes.cancelAudioIcon} />
</IconButton>
{loading ? (
<div>
<CircularProgress className={classes.audioLoading} />
</div>
) : (
<RecordingTimer />
)}
<input
multiple
type="file"
id="upload-button"
disabled={loading || recording || ticketStatus !== "open"}
className={classes.uploadInput}
onChange={handleChangeMedias}
/>
<label htmlFor="upload-button">
<IconButton <IconButton
aria-label="sendRecordedAudio" aria-label="upload"
component="span" component="span"
onClick={handleUploadAudio} disabled={loading || recording || ticketStatus !== "open"}
>
<AttachFileIcon className={classes.sendMessageIcons} />
</IconButton>
</label>
<div className={classes.messageInputWrapper}>
<InputBase
inputRef={input => input && input.focus()}
className={classes.messageInput}
placeholder={
ticketStatus === "open"
? i18n.t("messagesInput.placeholderOpen")
: i18n.t("messagesInput.placeholderClosed")
}
multiline
rowsMax={5}
value={inputMessage}
onChange={handleChangeInput}
disabled={recording || loading || ticketStatus !== "open"}
onPaste={e => {
ticketStatus === "open" && handleInputPaste(e);
}}
onKeyPress={e => {
if (loading || e.shiftKey) return;
else if (e.key === "Enter") {
handleSendMessage();
}
}}
/>
</div>
{inputMessage ? (
<IconButton
aria-label="sendMessage"
component="span"
onClick={handleSendMessage}
disabled={loading} disabled={loading}
> >
<CheckCircleOutlineIcon className={classes.sendAudioIcon} /> <SendIcon className={classes.sendMessageIcons} />
</IconButton> </IconButton>
</div> ) : recording ? (
) : ( <div className={classes.recorderWrapper}>
<IconButton <IconButton
aria-label="showRecorder" aria-label="cancelRecording"
component="span" component="span"
disabled={loading || ticketStatus !== "open"} fontSize="large"
onClick={handleStartRecording} disabled={loading}
> onClick={handleCancelAudio}
<MicIcon className={classes.sendMessageIcons} /> >
</IconButton> <HighlightOffIcon className={classes.cancelAudioIcon} />
)} </IconButton>
{loading ? (
<div>
<CircularProgress className={classes.audioLoading} />
</div>
) : (
<RecordingTimer />
)}
<IconButton
aria-label="sendRecordedAudio"
component="span"
onClick={handleUploadAudio}
disabled={loading}
>
<CheckCircleOutlineIcon className={classes.sendAudioIcon} />
</IconButton>
</div>
) : (
<IconButton
aria-label="showRecorder"
component="span"
disabled={loading || ticketStatus !== "open"}
onClick={handleStartRecording}
>
<MicIcon className={classes.sendMessageIcons} />
</IconButton>
)}
</div>
</Paper> </Paper>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useContext } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
@@ -8,13 +8,15 @@ import { i18n } from "../../translate/i18n";
import api from "../../services/api"; import api from "../../services/api";
import ConfirmationModal from "../ConfirmationModal"; import ConfirmationModal from "../ConfirmationModal";
import { Menu } from "@material-ui/core"; import { Menu } from "@material-ui/core";
import { ReplyMessageContext } from "../../context/ReplyingMessage/ReplyingMessageContext";
const MessageOptionsMenu = ({ messageId, menuOpen, handleClose, anchorEl }) => { const MessageOptionsMenu = ({ message, menuOpen, handleClose, anchorEl }) => {
const { setReplyingMessage } = useContext(ReplyMessageContext);
const [confirmationOpen, setConfirmationOpen] = useState(false); const [confirmationOpen, setConfirmationOpen] = useState(false);
const handleDeleteMessage = async () => { const handleDeleteMessage = async () => {
try { try {
await api.delete(`/messages/${messageId}`); await api.delete(`/messages/${message.id}`);
} catch (err) { } catch (err) {
const errorMsg = err.response?.data?.error; const errorMsg = err.response?.data?.error;
if (errorMsg) { if (errorMsg) {
@@ -29,6 +31,11 @@ const MessageOptionsMenu = ({ messageId, menuOpen, handleClose, anchorEl }) => {
} }
}; };
const hanldeReplyMessage = () => {
setReplyingMessage(message);
handleClose();
};
const handleOpenConfirmationModal = e => { const handleOpenConfirmationModal = e => {
setConfirmationOpen(true); setConfirmationOpen(true);
handleClose(); handleClose();
@@ -61,7 +68,9 @@ const MessageOptionsMenu = ({ messageId, menuOpen, handleClose, anchorEl }) => {
<MenuItem onClick={handleOpenConfirmationModal}> <MenuItem onClick={handleOpenConfirmationModal}>
{i18n.t("messageOptionsMenu.delete")} {i18n.t("messageOptionsMenu.delete")}
</MenuItem> </MenuItem>
<MenuItem disabled> {i18n.t("messageOptionsMenu.reply")}</MenuItem> <MenuItem onClick={hanldeReplyMessage}>
{i18n.t("messageOptionsMenu.reply")}
</MenuItem>
</Menu> </Menu>
</> </>
); );

View File

@@ -295,7 +295,7 @@ const reducer = (state, action) => {
} }
}; };
const MessagesList = ({ ticketId, isGroup }) => { const MessagesList = ({ ticketId, isGroup, setReplyingMessage }) => {
const classes = useStyles(); const classes = useStyles();
const [messagesList, dispatch] = useReducer(reducer, []); const [messagesList, dispatch] = useReducer(reducer, []);
@@ -304,7 +304,7 @@ const MessagesList = ({ ticketId, isGroup }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const lastMessageRef = useRef(); const lastMessageRef = useRef();
const [selectedMessageId, setSelectedMessageId] = useState(null); const [selectedMessage, setSelectedMessage] = useState({});
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
const messageOptionsMenuOpen = Boolean(anchorEl); const messageOptionsMenuOpen = Boolean(anchorEl);
const currentTicketId = useRef(ticketId); const currentTicketId = useRef(ticketId);
@@ -402,9 +402,9 @@ const MessagesList = ({ ticketId, isGroup }) => {
} }
}; };
const handleOpenMessageOptionsMenu = (e, messageId) => { const handleOpenMessageOptionsMenu = (e, message) => {
setAnchorEl(e.currentTarget); setAnchorEl(e.currentTarget);
setSelectedMessageId(messageId); setSelectedMessage(message);
}; };
const handleCloseMessageOptionsMenu = e => { const handleCloseMessageOptionsMenu = e => {
@@ -581,7 +581,7 @@ const MessagesList = ({ ticketId, isGroup }) => {
id="messageActionsButton" id="messageActionsButton"
disabled={message.isDeleted} disabled={message.isDeleted}
className={classes.messageActionsButton} className={classes.messageActionsButton}
onClick={e => handleOpenMessageOptionsMenu(e, message.id)} onClick={e => handleOpenMessageOptionsMenu(e, message)}
> >
<ExpandMore /> <ExpandMore />
</IconButton> </IconButton>
@@ -619,7 +619,7 @@ const MessagesList = ({ ticketId, isGroup }) => {
return ( return (
<div className={classes.messagesListWrapper}> <div className={classes.messagesListWrapper}>
<MessageOptionsMenu <MessageOptionsMenu
messageId={selectedMessageId} message={selectedMessage}
anchorEl={anchorEl} anchorEl={anchorEl}
menuOpen={messageOptionsMenuOpen} menuOpen={messageOptionsMenuOpen}
handleClose={handleCloseMessageOptionsMenu} handleClose={handleCloseMessageOptionsMenu}

View File

@@ -15,6 +15,7 @@ import TicketActionButtons from "../TicketActionButtons";
import MessagesList from "../MessagesList"; import MessagesList from "../MessagesList";
import api from "../../services/api"; import api from "../../services/api";
import { i18n } from "../../translate/i18n"; import { i18n } from "../../translate/i18n";
import { ReplyMessageProvider } from "../../context/ReplyingMessage/ReplyingMessageContext";
const drawerWidth = 320; const drawerWidth = 320;
@@ -151,11 +152,13 @@ const Ticket = () => {
/> />
<TicketActionButtons ticket={ticket} /> <TicketActionButtons ticket={ticket} />
</TicketHeader> </TicketHeader>
<MessagesList <ReplyMessageProvider>
ticketId={ticketId} <MessagesList
isGroup={ticket.isGroup} ticketId={ticketId}
></MessagesList> isGroup={ticket.isGroup}
<MessageInput ticketStatus={ticket.status} /> ></MessagesList>
<MessageInput ticketStatus={ticket.status} />
</ReplyMessageProvider>
</Paper> </Paper>
<ContactDrawer <ContactDrawer
open={drawerOpen} open={drawerOpen}

View File

@@ -0,0 +1,17 @@
import React, { useState, createContext } from "react";
const ReplyMessageContext = createContext();
const ReplyMessageProvider = ({ children }) => {
const [replyingMessage, setReplyingMessage] = useState({});
return (
<ReplyMessageContext.Provider
value={{ replyingMessage, setReplyingMessage }}
>
{children}
</ReplyMessageContext.Provider>
);
};
export { ReplyMessageContext, ReplyMessageProvider };