@selfcommunity/react-ui
Version:
React UI Components to integrate a Community created with SelfCommunity Platform.
490 lines (481 loc) • 24.7 kB
JavaScript
import { __rest } from "tslib";
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { styled } from '@mui/material/styles';
import { Endpoints, http, PrivateMessageService } from '@selfcommunity/api-services';
import { SCUserContext, UserUtils, useSCFetchUser } from '@selfcommunity/react-core';
import { SCNotificationTopicType, SCNotificationTypologyType, SCPrivateMessageStatusType, SCPrivateMessageType } from '@selfcommunity/types';
import PrivateMessageThreadItem, { PrivateMessageThreadItemSkeleton } from '../PrivateMessageThreadItem';
import PubSub from 'pubsub-js';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Avatar, Box, Card, CardContent, IconButton, List, ListItemAvatar, ListSubheader, TextField, Typography } from '@mui/material';
import PrivateMessageEditor from '../PrivateMessageEditor';
import Autocomplete from '@mui/material/Autocomplete';
import classNames from 'classnames';
import { useThemeProps } from '@mui/system';
import Icon from '@mui/material/Icon';
import PrivateMessageThreadSkeleton from './Skeleton';
import { SCOPE_SC_UI } from '../../constants/Errors';
import { groupBy, Logger } from '@selfcommunity/utils';
import { useSnackbar } from 'notistack';
import ConfirmDialog from '../../shared/ConfirmDialog/ConfirmDialog';
import InfiniteScroll from '../../shared/InfiniteScroll';
import { PREFIX } from './constants';
const translMessages = defineMessages({
placeholder: {
id: 'ui.privateMessage.thread.newMessage.autocomplete.placeholder',
defaultMessage: 'ui.privateMessage.thread.newMessage.autocomplete.placeholder'
},
messageDeleted: {
id: 'ui.privateMessage.thread.message.deleted',
defaultMessage: 'ui.privateMessage.thread.message.deleted'
}
});
const classes = {
root: `${PREFIX}-root`,
subHeader: `${PREFIX}-subheader`,
section: `${PREFIX}-section`,
emptyMessage: `${PREFIX}-empty-message`,
newMessageHeader: `${PREFIX}-new-message-header`,
newMessageHeaderContent: `${PREFIX}-new-message-header-content`,
newMessageHeaderIcon: `${PREFIX}-new-message-header-icon`,
newMessageContent: `${PREFIX}-new-message-content`,
sender: `${PREFIX}-sender`,
receiver: `${PREFIX}-receiver`,
avatar: `${PREFIX}-avatar`,
item: `${PREFIX}-item`,
autocomplete: `${PREFIX}-autocomplete`,
autocompleteDialog: `${PREFIX}-autocomplete-dialog`,
editor: `${PREFIX}-editor`
};
const Root = styled(Card, {
name: PREFIX,
slot: 'Root'
})(() => ({}));
/**
* > API documentation for the Community-JS PrivateMessage Thread component. Learn about the available props and the CSS API.
*
*
* This component renders the conversation between two users.
* Take a look at our <strong>demo</strong> component [here](/docs/sdk/community-js/react-ui/Components/Thread)
#### Import
```jsx
import {PrivateMessageThread} from '@selfcommunity/react-ui';
```
#### Component Name
The name `SCPrivateMessageThread` can be used when providing style overrides in the theme.
#### CSS
|Rule Name|Global class|Description|
|---|---|---|
|root|.SCPrivateMessageThread-root|Styles applied to the root element.|
|subHeader|.SCPrivateMessageThread-subheader|Styles applied to thread list subheader element.|
|section|.SCPrivateMessageThread-section|Styles applied to the list section|
|emptyMessage|.SCPrivateMessageThread-empty-message|Styles applied to the empty message element.|
|newMessageHeader|.SCPrivateMessageThread-new-message-header|Styles applied to the new message header section.|
|newMessageHeaderContent|.SCPrivateMessageThread-new-message-header-content|Styles applied to the new message header content.|
|newMessageHeaderIcon|.SCPrivateMessageThread-new-message-header-icon|Styles applied to the new message header icon element.|
|newMessageContent|.SCPrivateMessageThread-new-message-content|Styles applied to the new message content.|
|sender|.SCPrivateMessageThread-sender|Styles applied to the sender element.|
|receiver|.SCPrivateMessageThread-receiver|Styles applied to the receiver element.|
|autocomplete|.SCPrivateMessageThread-autocomplete|Styles applied to autocomplete element.|
|autocompleteDialog|.SCPrivateMessageThread-autocomplete-dialog|Styles applied to autocomplete dialog element.|
|editor|.SCPrivateMessageThread-editor|Styles applied to the editor element.|
* @param inProps
*/
export default function PrivateMessageThread(inProps) {
// PROPS
const props = useThemeProps({
props: inProps,
name: PREFIX
});
const { threadObj, openNewMessage = false, onNewMessageClose = null, onNewMessageSent = null, onSingleMessageOpen = null, className, type } = props, rest = __rest(props, ["threadObj", "openNewMessage", "onNewMessageClose", "onNewMessageSent", "onSingleMessageOpen", "className", "type"]);
// CONTEXT
const scUserContext = useContext(SCUserContext);
// STATE
const [value, setValue] = useState('');
const [previous, setPrevious] = useState(null);
const [messageObjs, setMessageObjs] = useState([]);
const [loadingMessageObjs, setLoadingMessageObjs] = useState(true);
const [loading, setLoading] = useState(false);
const [isHovered, setIsHovered] = useState({});
const [followers, setFollowers] = useState([]);
const isNew = threadObj && threadObj === SCPrivateMessageStatusType.NEW;
const authUserId = scUserContext.user ? scUserContext.user.id : null;
const [singleMessageUser, setSingleMessageUser] = useState(null);
const [receiver, setReceiver] = useState(null);
const [deletingMsg, setDeletingMsg] = useState(null);
const [singleMessageThread, setSingleMessageThread] = useState(false);
const [openDeleteMessageDialog, setOpenDeleteMessageDialog] = useState(false);
const [recipients, setRecipients] = useState([]);
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const isNumber = typeof threadObj === 'number';
const messageReceiver = (item, loggedUserId) => {
var _a, _b, _c;
return ((_a = item === null || item === void 0 ? void 0 : item.receiver) === null || _a === void 0 ? void 0 : _a.id) !== loggedUserId ? (_b = item === null || item === void 0 ? void 0 : item.receiver) === null || _b === void 0 ? void 0 : _b.id : (_c = item === null || item === void 0 ? void 0 : item.sender) === null || _c === void 0 ? void 0 : _c.id;
};
const [error, setError] = useState(false);
// REFS
const refreshSubscription = useRef(null);
// INTL
const intl = useIntl();
// HOOKS
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
const { scUser } = useSCFetchUser({ id: isNumber && threadObj, threadObj });
const messagesEndRef = useRef(null);
const scrollToBottom = () => {
var _a;
(_a = messagesEndRef.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView({ block: 'end', behavior: 'instant' });
};
// UTILS
const format = (item) => intl.formatDate(item.created_at, {
year: 'numeric',
day: 'numeric',
month: 'long'
});
// CONST
const formattedMessages = useMemo(() => {
const _messages = [...messageObjs];
return groupBy(_messages, format);
}, [messageObjs]);
// HANDLERS
const handleMouseEnter = (index) => {
setIsHovered((prevState) => {
return Object.assign(Object.assign({}, prevState), { [index]: true });
});
};
const handleMouseLeave = (index) => {
setIsHovered((prevState) => {
return Object.assign(Object.assign({}, prevState), { [index]: false });
});
};
/**
* Handles delete message dialog opening
* @param msg
*/
const handleOpenDeleteMessageDialog = (msg) => {
setOpenDeleteMessageDialog(true);
setDeletingMsg(msg);
};
/**
* Handles delete message dialog close
*/
const handleCloseDeleteMessageDialog = () => {
setOpenDeleteMessageDialog(false);
};
/**
* Memoized message recipients ids
*/
const ids = useMemo(() => {
if (!recipients)
return [];
return Array.isArray(recipients) ? recipients.map((u) => parseInt(u.id, 10)) : [parseInt(recipients.id || recipients, 10)];
}, [recipients, openNewMessage]);
function fetchResults() {
setLoading(true);
PrivateMessageService.searchUser(value)
.then((data) => {
setLoading(false);
setFollowers(data.results.filter((user) => user.id !== authUserId));
})
.catch((error) => {
setLoading(false);
console.log(error);
});
}
const handleInputChange = (event, value, reason) => {
switch (reason) {
case 'input':
setValue(value);
!value && setFollowers([]);
break;
case 'reset':
setValue(value);
break;
}
};
const handleChange = (event, value, reason) => {
event.preventDefault();
event.stopPropagation();
switch (reason) {
case 'selectOption':
handleClear(event);
setRecipients(value);
break;
case 'removeOption':
handleClear(event, value);
break;
}
return false;
};
const handleClear = (event, value) => {
setValue('');
setFollowers([]);
setRecipients(value !== null && value !== void 0 ? value : []);
};
const handleNewMessageClose = () => {
handleClear();
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
onNewMessageClose && onNewMessageClose();
};
function updateAndDeleteURLParameters(url, paramToUpdate, newValue, paramToDelete) {
const urlObj = new URL(url);
urlObj.searchParams.set(paramToUpdate, newValue);
urlObj.searchParams.delete(paramToDelete);
return urlObj.toString();
}
const handlePrevious = useMemo(() => () => {
if (!previous) {
return;
}
return http
.request({
url: previous,
method: Endpoints.GetAThread.method
})
.then((res) => {
const _prev = [...messageObjs];
_prev.unshift(...res.data.results);
setMessageObjs(_prev);
setPrevious(res.data.next && updateAndDeleteURLParameters(res.data.next, 'before_message', res.data.results[0].id, 'offset'));
})
.catch((error) => console.log(error));
}, [previous, messageObjs]);
/**
* Fetches thread
*/
function fetchThread() {
if (threadObj && typeof threadObj !== 'string' && type !== SCPrivateMessageType.GROUP) {
setLoadingMessageObjs(true);
const _userObjId = isNumber ? threadObj : messageReceiver(threadObj, authUserId);
PrivateMessageService.getAThread({ user: _userObjId, limit: 10 })
.then((res) => {
setMessageObjs(res.results);
setPrevious(res.next && updateAndDeleteURLParameters(res.next, 'before_message', res.results[0].id, 'offset'));
if (res.results.length) {
if (res.results[0].receiver.id !== authUserId) {
setReceiver(res.results[0].receiver);
}
else {
setReceiver(res.results[0].sender);
}
setSingleMessageThread(false);
}
else {
if (scUser === null || scUser === void 0 ? void 0 : scUser.can_send_pm_to) {
setSingleMessageThread(true);
setRecipients(_userObjId);
onSingleMessageOpen(true);
setSingleMessageUser(scUser);
}
else {
setSingleMessageThread(false);
}
}
setLoadingMessageObjs(false);
})
.catch((error) => {
setLoadingMessageObjs(false);
console.log(error);
Logger.error(SCOPE_SC_UI, { error });
});
}
else if (type === SCPrivateMessageType.GROUP) {
PrivateMessageService.getAThread({ group: isNumber ? threadObj : threadObj.group.id, limit: 10 })
.then((res) => {
setMessageObjs(res.results);
setPrevious(res.next && updateAndDeleteURLParameters(res.next, 'before_message', res.results[0].id, 'offset'));
setLoadingMessageObjs(false);
setSingleMessageThread(false);
})
.catch((error) => {
setLoadingMessageObjs(false);
console.log(error);
Logger.error(SCOPE_SC_UI, { error });
});
}
}
const isNewerThan60Seconds = (creationTime) => {
const date = new Date(creationTime);
const now = new Date();
return now.getTime() - date.getTime() < 60000;
};
function updateMessageAfterDeletion(id) {
const newMessageObjects = [...messageObjs];
const index = newMessageObjects.findIndex((s) => s.id === id);
if (index !== -1) {
newMessageObjects[index].message = `${intl.formatMessage(translMessages.messageDeleted)}`;
newMessageObjects[index].file = null;
newMessageObjects[index].status = SCPrivateMessageStatusType.HIDDEN;
setMessageObjs(newMessageObjects);
}
}
/**
* Handles the deletion of a single message
*/
function handleDeleteMessage() {
const toHide = isNewerThan60Seconds(deletingMsg.created_at);
PrivateMessageService.deleteAMessage(deletingMsg.id)
.then(() => {
const result = messageObjs.filter((m) => m.id !== deletingMsg.id);
toHide ? setMessageObjs(result) : updateMessageAfterDeletion(deletingMsg.id);
handleSnippetsUpdate((result.length >= 1 && toHide) || (!toHide && deletingMsg.id !== messageObjs.slice(-1)[0].id) ? result.slice(-1) : [deletingMsg]);
handleCloseDeleteMessageDialog();
})
.catch((error) => {
console.log(error);
let _snackBar = enqueueSnackbar(_jsx(FormattedMessage, { id: "ui.privateMessage.thread.error.delete.msg", defaultMessage: "ui.privateMessage.thread.error.delete.msg" }), {
variant: 'error',
SnackbarProps: {
onClick: () => {
closeSnackbar(_snackBar);
}
}
});
});
}
/**
* Updates snippets list when a new message(or more) are sent or another one is deleted
* @param message, it can be a single object or an array of objects
*/
function handleSnippetsUpdate(message) {
PubSub.publish('snippetsChannel', message);
}
/**
* Handles message sending
* @param message
* @param file
*/
function handleSend(message, file) {
if (UserUtils.isBlocked(scUserContext.user)) {
enqueueSnackbar(_jsx(FormattedMessage, { id: "ui.common.userBlocked", defaultMessage: "ui.common.userBlocked" }), {
variant: 'warning',
autoHideDuration: 3000
});
}
else {
http
.request({
url: Endpoints.SendMessage.url(),
method: Endpoints.SendMessage.method,
data: Object.assign(Object.assign({}, (type === SCPrivateMessageType.GROUP
? { group: isNumber ? threadObj : threadObj.group.id }
: {
recipients: openNewMessage || isNew || singleMessageThread
? ids
: [isNumber && threadObj ? threadObj : messageReceiver(threadObj, authUserId)]
})), {
// recipients: openNewMessage || isNew || singleMessageThread ? ids : [isNumber && threadObj ? threadObj : messageReceiver(threadObj, authUserId)],
message: message, file_uuid: file && !message ? file : null })
})
.then((res) => {
const isOne = res.data.length <= 1;
isOne && setMessageObjs((prev) => [...prev, res.data[0]]);
handleSnippetsUpdate(res.data);
if (openNewMessage || singleMessageThread) {
setSingleMessageThread(false);
onSingleMessageOpen(false);
setRecipients([]);
onNewMessageSent(res.data[0], isOne);
}
scrollToBottom();
})
.catch((error) => {
console.log(error);
setError(true);
});
}
}
/**
* Reset component state
*/
const reset = useCallback(() => () => {
setLoadingMessageObjs(true);
setMessageObjs([]);
setPrevious(null);
setReceiver(null);
setSingleMessageThread(false);
}, [setLoadingMessageObjs, setMessageObjs, setPrevious, setReceiver, setSingleMessageThread]);
// EFFECTS
/**
* If a value is entered in new message field, it fetches user followers
*/
useEffect(() => {
if (value) {
fetchResults();
}
}, [value]);
/**
* On mount, if obj, fetches thread
*/
useEffect(() => {
if (!authUserId) {
return;
}
if (threadObj) {
fetchThread();
}
else {
reset();
}
}, [threadObj, authUserId, scUser]);
/**
* Notification subscriber
*/
const subscriber = (msg, data) => {
const res = data.data;
const newMessages = [...messageObjs];
const index = newMessages.findIndex((m) => m.thread_id === res.thread_id);
const _message = res.notification_obj.message;
_message.receiver = res.notification_obj.snippet.receiver;
_message.thread_status = res.notification_obj.snippet.thread_status;
handleSnippetsUpdate([_message]);
if (index !== -1) {
setMessageObjs((prev) => [...prev, res.notification_obj.message]);
}
if (isNumber ? threadObj === res.thread_id : threadObj.id === res.thread_id) {
scrollToBottom();
}
};
/**
* When a ws notification arrives, updates thread and snippets data
*/
useEffect(() => {
refreshSubscription.current = PubSub.subscribe(`${SCNotificationTopicType.INTERACTION}.${SCNotificationTypologyType.PRIVATE_MESSAGE}`, subscriber);
return () => {
PubSub.unsubscribe(refreshSubscription.current);
};
}, [messageObjs]);
/**
* Renders thread component
* @return {JSX.Element}
*/
function renderThread() {
if (loadingMessageObjs) {
return _jsx(PrivateMessageThreadSkeleton, {});
}
return (_jsxs(CardContent, { children: [_jsx(InfiniteScroll, Object.assign({ height: '100%', dataLength: messageObjs.length, previous: handlePrevious, inverse: true, hasMorePrevious: Boolean(previous), loaderPrevious: _jsx(PrivateMessageThreadItemSkeleton, {}) }, { children: _jsx(List, Object.assign({ ref: messagesEndRef }, { children: Object.keys(formattedMessages).map((key) => (_jsx("li", Object.assign({ className: classes.section }, { children: _jsxs("ul", { children: [_jsx(ListSubheader, { children: _jsx(Typography, Object.assign({ align: "center", className: classes.subHeader }, { children: key })) }), formattedMessages[key].map((msg) => {
var _a;
return (_jsxs(Box, Object.assign({ className: classes.item }, { children: [msg.group && ((_a = scUserContext === null || scUserContext === void 0 ? void 0 : scUserContext.user) === null || _a === void 0 ? void 0 : _a.username) !== msg.sender.username && (_jsx(ListItemAvatar, { children: _jsx(Avatar, { alt: msg.sender.username, src: msg.sender.avatar, className: classes.avatar }) })), _jsx(PrivateMessageThreadItem, { className: authUserId === msg.sender.id ? classes.sender : classes.receiver, message: msg, mouseEvents: {
onMouseEnter: () => handleMouseEnter(msg.id),
onMouseLeave: () => handleMouseLeave(msg.id)
}, isHovering: isHovered[msg.id], showMenuIcon: authUserId === msg.sender.id, onMenuIconClick: () => handleOpenDeleteMessageDialog(msg) }, msg.id)] }), msg.id));
})] }) }), key))) })) })), _jsx(PrivateMessageEditor, { className: classes.editor, send: handleSend, autoHide: type !== SCPrivateMessageType.GROUP && !(scUser === null || scUser === void 0 ? void 0 : scUser.can_send_pm_to), autoHideDeletion: type === SCPrivateMessageType.USER && ((receiver === null || receiver === void 0 ? void 0 : receiver.deleted) || (scUser === null || scUser === void 0 ? void 0 : scUser.deleted)), onThreadChangeId: isNumber ? threadObj : type === SCPrivateMessageType.USER ? threadObj.receiver.id : threadObj.group.id, error: error, onErrorRemove: () => setError(false) }), openDeleteMessageDialog && (_jsx(ConfirmDialog, { open: openDeleteMessageDialog, title: _jsx(FormattedMessage, { id: "ui.privateMessage.component.delete.message.dialog.msg", defaultMessage: "ui.privateMessage.component.delete.message.dialog.msg" }), btnConfirm: _jsx(FormattedMessage, { id: "ui.privateMessage.component.delete.message.dialog.confirm", defaultMessage: "ui.privateMessage.component.delete.message.dialog.confirm" }), onConfirm: handleDeleteMessage, onClose: handleCloseDeleteMessageDialog }))] }));
}
/**
* Renders empty box (when there is no thread open) or new message box
* @return {JSX.Element}
*/
function renderNewOrNoMessageBox() {
return (_jsx(CardContent, { children: isNew || singleMessageThread ? (_jsxs(_Fragment, { children: [_jsxs(Box, Object.assign({ className: classes.newMessageHeader }, { children: [_jsxs(Box, Object.assign({ className: classes.newMessageHeaderContent }, { children: [_jsx(Icon, Object.assign({ className: classes.newMessageHeaderIcon }, { children: "person" })), _jsx(Typography, { children: _jsx(FormattedMessage, { defaultMessage: "ui.privateMessage.thread.newMessage.to", id: "ui.privateMessage.thread.newMessage.to" }) }), _jsx(Autocomplete, { className: classes.autocomplete, loading: loading, multiple: !singleMessageThread, limitTags: 3, freeSolo: true, disableClearable: true, options: followers, onChange: handleChange, onInputChange: handleInputChange, inputValue: value, value: singleMessageThread ? singleMessageUser : recipients, getOptionLabel: (option) => (option ? option.username : '...'), isOptionEqualToValue: (option, value) => (option ? value.id === option.id : false), renderInput: (params) => (_jsx(TextField, Object.assign({}, params, { placeholder: singleMessageThread ? '...' : `${intl.formatMessage(translMessages.placeholder)}`, variant: "standard", InputProps: Object.assign(Object.assign({}, params.InputProps), { disableUnderline: true }) }))), classes: { popper: classes.autocompleteDialog }, disabled: Boolean(singleMessageUser) })] })), _jsx(IconButton, Object.assign({ size: "small", onClick: handleNewMessageClose }, { children: _jsx(Icon, { children: "close" }) }))] })), _jsx(List, { className: classes.newMessageContent }), _jsx(PrivateMessageEditor, { className: classes.editor, send: handleSend, autoHide: !followers, error: error, onErrorRemove: () => setError(false) })] })) : (_jsx(Typography, Object.assign({ className: classes.emptyMessage }, { children: _jsx(FormattedMessage, { id: "ui.privateMessage.thread.emptyBox.message", defaultMessage: "ui.privateMessage.thread.emptyBox.message" }) }))) }));
}
// Anonymous
if (!authUserId) {
return null;
}
/**
* Renders the component
*/
return (_jsx(Root, Object.assign({}, rest, { className: classNames(classes.root, className) }, { children: threadObj !== null && !isNew && !singleMessageThread ? renderThread() : renderNewOrNoMessageBox() })));
}