Merge pull request #11173 from Tainan404/develop+pr10919+pr10838

Chat with adapter and context
This commit is contained in:
Anton Georgiev 2021-02-12 16:35:22 -05:00 committed by GitHub
commit 1476deb268
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 2215 additions and 288 deletions

View File

@ -25,6 +25,10 @@ import JoinHandler from '/imports/ui/components/join-handler/component';
import AuthenticatedHandler from '/imports/ui/components/authenticated-handler/component';
import Subscriptions from '/imports/ui/components/subscriptions/component';
import IntlStartup from '/imports/startup/client/intl';
import ContextProviders from '/imports/ui/components/context-providers/component';
import ChatAdapter from '/imports/ui/components/components-data/chat-context/adapter';
import UsersAdapter from '/imports/ui/components/components-data/users-context/adapter';
import GroupChatAdapter from '/imports/ui/components/components-data/group-chat-context/adapter';
Meteor.startup(() => {
// Logs all uncaught exceptions to the client logger
@ -51,15 +55,22 @@ Meteor.startup(() => {
// TODO make this a Promise
render(
<JoinHandler>
<AuthenticatedHandler>
<Subscriptions>
<IntlStartup>
<Base />
</IntlStartup>
</Subscriptions>
</AuthenticatedHandler>
</JoinHandler>,
<ContextProviders>
<React.Fragment>
<JoinHandler>
<AuthenticatedHandler>
<Subscriptions>
<IntlStartup>
<Base />
</IntlStartup>
</Subscriptions>
</AuthenticatedHandler>
</JoinHandler>
<UsersAdapter />
<ChatAdapter />
<GroupChatAdapter />
</React.Fragment>
</ContextProviders>,
document.getElementById('app'),
);
});

View File

@ -7,6 +7,7 @@ export default function clearGroupChatMsg(meetingId, chatId) {
const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid;
const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
const CHAT_CLEAR_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_clear;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
if (chatId) {
try {
@ -15,6 +16,7 @@ export default function clearGroupChatMsg(meetingId, chatId) {
if (numberAffected) {
Logger.info(`Cleared GroupChatMsg (${meetingId}, ${chatId})`);
const clearMsg = {
id: `${SYSTEM_CHAT_TYPE}-${CHAT_CLEAR_MESSAGE}`,
color: '0',
timestamp: Date.now(),
correlationId: `${PUBLIC_CHAT_SYSTEM_ID}-${Date.now()}`,

View File

@ -19,12 +19,13 @@ function groupChatMsg(chatsIds) {
Logger.debug('Publishing group-chat-msg', { meetingId, userId });
return GroupChatMsg.find({
const selector = {
$or: [
{ meetingId, chatId: { $eq: PUBLIC_GROUP_CHAT_ID } },
{ chatId: { $in: chatsIds } },
],
});
};
return GroupChatMsg.find(selector);
}
function publish(...args) {

View File

@ -12,7 +12,8 @@ const ChatAlertContainer = props => (
export default withTracker(() => {
const AppSettings = Settings.application;
const activeChats = UserListService.getActiveChats();
const activeChats = [];
// UserListService.getActiveChats();
const { loginTime } = Users.findOne({ userId: Auth.userID }, { fields: { loginTime: 1 } });
const openPanel = Session.get('openPanel');

View File

@ -50,10 +50,20 @@ class ChatDropdown extends PureComponent {
componentDidMount() {
this.clipboard = new Clipboard('#clipboardButton', {
text: () => ChatService.exportChat(ChatService.getPublicGroupMessages()),
text: () => '',
});
}
componentDidUpdate(prevProps, prevState) {
const { timeWindowsValues } = this.props;
const { isSettingOpen } = this.state;
if (prevState.isSettingOpen !== isSettingOpen) {
this.clipboard = new Clipboard('#clipboardButton', {
text: () => ChatService.exportChat(timeWindowsValues),
});
}
}
componentWillUnmount() {
this.clipboard.destroy();
}
@ -72,13 +82,12 @@ class ChatDropdown extends PureComponent {
getAvailableActions() {
const {
intl, isMeteorConnected, amIModerator, meetingIsBreakout, meetingName,
intl, isMeteorConnected, amIModerator, meetingIsBreakout, meetingName, timeWindowsValues,
} = this.props;
const clearIcon = 'delete';
const saveIcon = 'download';
const copyIcon = 'copy';
return _.compact([
<DropdownListItem
data-test="chatSave"
@ -91,12 +100,11 @@ class ChatDropdown extends PureComponent {
const date = new Date();
const time = `${date.getHours()}-${date.getMinutes()}`;
const dateString = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}_${time}`;
link.setAttribute('download', `bbb-${meetingName}[public-chat]_${dateString}.txt`);
link.setAttribute(
'href',
`data: ${mimeType} ;charset=utf-8,
${encodeURIComponent(ChatService.exportChat(ChatService.getPublicGroupMessages()))}`,
${encodeURIComponent(ChatService.exportChat(timeWindowsValues))}`,
);
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
}}

View File

@ -0,0 +1,37 @@
class ChatLogger {
constructor() {
this.logLevel = 'info';
this.levels = Object.freeze({
error: 1,
info: 2,
debug: 3,
trace: 4,
});
Object.keys(this.levels).forEach((i) => {
this[i] = this.logger.bind(this, i);
});
}
setLogLevel(level) {
if (this.levels[level]) {
this.logLevel = level;
} else {
throw new Error('This Level not exist');
}
}
getLogLevel() {
return this.logLevel;
}
logger(level, ...text) {
const logLevel = this.levels[level];
if (this.levels[this.logLevel] >= logLevel) {
console.log(`${level}:`, ...text);
}
}
}
export default new ChatLogger();

View File

@ -7,7 +7,7 @@ import { Session } from 'meteor/session';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import { styles } from './styles.scss';
import MessageForm from './message-form/container';
import MessageList from './message-list/container';
import TimeWindowList from './time-window-list/container';
import ChatDropdownContainer from './chat-dropdown/container';
const ELEMENT_ID = 'chat-messages';
@ -22,10 +22,10 @@ const intlMessages = defineMessages({
description: 'aria-label for hiding chat button',
},
});
const Chat = (props) => {
const {
chatID,
chatName,
title,
messages,
partnerIsLoggedOut,
@ -42,11 +42,12 @@ const Chat = (props) => {
maxMessageLength,
amIModerator,
meetingIsBreakout,
timeWindowsValues,
dispatch,
count,
} = props;
const HIDE_CHAT_AK = shortcuts.hidePrivateChat;
const CLOSE_CHAT_AK = shortcuts.closePrivateChat;
return (
<div
data-test={chatID !== 'public' ? 'privateChat' : 'publicChat'}
@ -90,26 +91,29 @@ const Chat = (props) => {
accessKey={CLOSE_CHAT_AK}
/>
)
: <ChatDropdownContainer {...{ meetingIsBreakout, isMeteorConnected, amIModerator }} />
: <ChatDropdownContainer {...{ meetingIsBreakout, isMeteorConnected, amIModerator, timeWindowsValues }} />
}
</header>
<MessageList
<TimeWindowList
id={ELEMENT_ID}
chatId={chatID}
handleScrollUpdate={actions.handleScrollUpdate}
handleReadMessage={actions.handleReadMessage}
{...{
partnerIsLoggedOut,
lastReadMessageTime,
hasUnreadMessages,
scrollPosition,
messages,
currentUserIsModerator: amIModerator,
timeWindowsValues,
dispatch,
count,
}}
/>
<MessageForm
{...{
UnsentMessagesCollection,
chatName,
title,
minMessageLength,
maxMessageLength,
}}
@ -130,7 +134,6 @@ export default withShortcutHelper(injectWbResizeEvent(injectIntl(memo(Chat))), [
const propTypes = {
chatID: PropTypes.string.isRequired,
chatName: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
messages: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.oneOfType([
PropTypes.array,

View File

@ -1,19 +1,30 @@
import React, { PureComponent } from 'react';
import React, { useEffect, useContext, useState } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { withTracker } from 'meteor/react-meteor-data';
import { Session } from 'meteor/session';
import _ from 'lodash';
import Auth from '/imports/ui/services/auth';
import Storage from '/imports/ui/services/storage/session';
import { meetingIsBreakout } from '/imports/ui/components/app/service';
import { ChatContext, getLoginTime } from '../components-data/chat-context/context';
import { GroupChatContext } from '../components-data/group-chat-context/context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import Chat from './component';
import ChatService from './service';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
const CHAT_CLEAR = CHAT_CONFIG.system_messages_keys.chat_clear;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const CONNECTION_STATUS = 'online';
const DEBOUNCE_TIME = 1000;
const sysMessagesIds = {
welcomeId: `${SYSTEM_CHAT_TYPE}-welcome-msg`,
moderatorId: `${SYSTEM_CHAT_TYPE}-moderator-msg`
};
const intlMessages = defineMessages({
[CHAT_CLEAR]: {
@ -34,153 +45,144 @@ const intlMessages = defineMessages({
},
});
class ChatContainer extends PureComponent {
componentDidMount() {
// in case of reopening a chat, need to make sure it's removed from closed list
let previousChatId = null;
let debounceTimeout = null;
let messages = null;
let globalAppplyStateToProps = () => { }
const throttledFunc = _.throttle(() => {
globalAppplyStateToProps();
}, DEBOUNCE_TIME, { trailing: true, leading: true });
const ChatContainer = (props) => {
useEffect(() => {
ChatService.removeFromClosedChatsSession();
}
}, []);
render() {
const {
children,
unmounting,
} = this.props;
const modOnlyMessage = Storage.getItem('ModeratorOnlyMessage');
const { welcomeProp } = ChatService.getWelcomeProp();
if (unmounting === true) {
return null;
const {
children,
unmounting,
chatID,
amIModerator,
loginTime,
intl,
} = props;
const isPublicChat = chatID === PUBLIC_CHAT_KEY;
const systemMessages = {
[sysMessagesIds.welcomeId]: {
id: sysMessagesIds.welcomeId,
content: [{
id: sysMessagesIds.welcomeId,
text: welcomeProp.welcomeMsg,
time: loginTime,
}],
key: sysMessagesIds.welcomeId,
time: loginTime,
sender: null,
},
[sysMessagesIds.moderatorId]: {
id: sysMessagesIds.moderatorId,
content: [{
id: sysMessagesIds.moderatorId,
text: modOnlyMessage,
time: loginTime + 1,
}],
key: sysMessagesIds.moderatorId,
time: loginTime + 1,
sender: null,
}
};
return (
<Chat {...this.props}>
{children}
</Chat>
);
const systemMessagesIds = [sysMessagesIds.welcomeId, amIModerator && modOnlyMessage && sysMessagesIds.moderatorId].filter(i => i);
const usingChatContext = useContext(ChatContext);
const usingGroupChatContext = useContext(GroupChatContext);
const [stateLastMsg, setLastMsg] = useState(null);
const [stateTimeWindows, setTimeWindows] = useState(isPublicChat ? [...systemMessagesIds.map((item) => systemMessages[item])] : []);
const { groupChat } = usingGroupChatContext;
const participants = groupChat[chatID]?.participants;
const chatName = participants?.filter((user) => user.id !== Auth.userID)[0]?.name;
const title = chatName ? intl.formatMessage(intlMessages.titlePrivate, { 0: chatName}) : intl.formatMessage(intlMessages.titlePublic);
if (unmounting === true) {
return null;
}
}
const contextChat = usingChatContext?.chats[isPublicChat ? PUBLIC_GROUP_CHAT_KEY : chatID];
const lastTimeWindow = contextChat?.lastTimewindow;
const lastMsg = contextChat && (isPublicChat
? contextChat.preJoinMessages[lastTimeWindow] || contextChat.posJoinMessages[lastTimeWindow]
: contextChat.messageGroups[lastTimeWindow]);
applyPropsToState = () => {
if (!_.isEqualWith(lastMsg, stateLastMsg) || previousChatId !== chatID) {
const timeWindowsValues = isPublicChat
? [...Object.values(contextChat?.preJoinMessages || {}), ...systemMessagesIds.map((item) => systemMessages[item]),
...Object.values(contextChat?.posJoinMessages || {})]
: [...Object.values(contextChat?.messageGroups || {})];
if (previousChatId !== chatID) {
previousChatId = chatID;
}
setLastMsg(lastMsg ? { ...lastMsg } : lastMsg);
setTimeWindows(timeWindowsValues);
}
}
globalAppplyStateToProps = applyPropsToState;
throttledFunc();
return (
<Chat {...{
...props,
chatID,
amIModerator,
count: (contextChat?.unreadTimeWindows.size || 0),
timeWindowsValues: stateTimeWindows,
dispatch: usingChatContext?.dispatch,
title,
chatName,
contextChat,
}}>
{children}
</Chat>
);
};
export default injectIntl(withTracker(({ intl }) => {
const chatID = Session.get('idChatOpen');
let messages = [];
let isChatLocked = ChatService.isChatLocked(chatID);
let title = intl.formatMessage(intlMessages.titlePublic);
let chatName = title;
// let chatName = title;
let partnerIsLoggedOut = false;
let systemMessageIntl = {};
const currentUser = ChatService.getUser(Auth.userID);
const amIModerator = currentUser.role === ROLE_MODERATOR;
if (chatID === PUBLIC_CHAT_KEY) {
const { welcomeProp } = ChatService.getWelcomeProp();
messages = ChatService.getPublicGroupMessages();
const time = currentUser.loginTime;
const welcomeId = `welcome-msg-${time}`;
const welcomeMsg = {
id: welcomeId,
content: [{
id: welcomeId,
text: welcomeProp.welcomeMsg,
time,
}],
time,
sender: null,
};
let moderatorMsg;
const modOnlyMessage = Storage.getItem('ModeratorOnlyMessage');
if (amIModerator && modOnlyMessage) {
const moderatorTime = time + 1;
const moderatorId = `moderator-msg-${moderatorTime}`;
moderatorMsg = {
id: moderatorId,
content: [{
id: moderatorId,
text: modOnlyMessage,
time: moderatorTime,
}],
time: moderatorTime,
sender: null,
};
}
const messagesBeforeWelcomeMsg = ChatService.reduceAndDontMapGroupMessages(
messages.filter(message => message.timestamp < time),
);
const messagesAfterWelcomeMsg = ChatService.reduceAndDontMapGroupMessages(
messages.filter(message => message.timestamp >= time),
);
const messagesFormated = messagesBeforeWelcomeMsg
.concat(welcomeMsg)
.concat((amIModerator && modOnlyMessage) ? moderatorMsg : [])
.concat(messagesAfterWelcomeMsg);
messages = messagesFormated.sort((a, b) => (a.time - b.time));
} else if (chatID) {
messages = ChatService.getPrivateGroupMessages();
const receiverUser = ChatService.getUser(chatID);
const privateChat = ChatService.getPrivateChatByUsers(chatID);
chatName = receiverUser?.name || privateChat.participants.filter(u => u.id === chatID).pop().name;
systemMessageIntl = { 0: chatName };
title = intl.formatMessage(intlMessages.titlePrivate, systemMessageIntl);
partnerIsLoggedOut = !!receiverUser;
if (!partnerIsLoggedOut) {
const time = Date.now();
const id = `partner-disconnected-${time}`;
const messagePartnerLoggedOut = {
id,
content: [{
id,
text: 'partnerDisconnected',
time,
}],
time,
sender: null,
};
messages.push(messagePartnerLoggedOut);
isChatLocked = true;
}
} else {
if (!chatID) {
// No chatID is set so the panel is closed, about to close, or wasn't opened correctly
return {
unmounting: true,
};
}
messages = messages.map((message) => {
if (message.sender && message.sender.id !== SYSTEM_CHAT_TYPE) return message;
return {
...message,
content: message.content ? message.content.map(content => ({
...content,
text: content.text in intlMessages
? `<b><i>${intl.formatMessage(intlMessages[content.text], systemMessageIntl)}</i></b>` : content.text,
})) : [],
};
});
const { connected: isMeteorConnected } = Meteor.status();
return {
chatID,
chatName,
title,
messages,
intl,
messages: [],
partnerIsLoggedOut,
isChatLocked,
isMeteorConnected,
amIModerator,
meetingIsBreakout: meetingIsBreakout(),
loginTime: getLoginTime(),
actions: {
handleClosePrivateChat: ChatService.closePrivateChat,
},

View File

@ -16,7 +16,6 @@ const propTypes = {
minMessageLength: PropTypes.number.isRequired,
maxMessageLength: PropTypes.number.isRequired,
chatTitle: PropTypes.string.isRequired,
chatName: PropTypes.string.isRequired,
className: PropTypes.string,
chatAreaId: PropTypes.string.isRequired,
handleSendMessage: PropTypes.func.isRequired,
@ -267,7 +266,7 @@ class MessageForm extends PureComponent {
const {
intl,
chatTitle,
chatName,
title,
disabled,
className,
chatAreaId,
@ -286,7 +285,7 @@ class MessageForm extends PureComponent {
className={styles.input}
id="message-input"
innerRef={(ref) => { this.textarea = ref; return this.textarea; }}
placeholder={intl.formatMessage(messages.inputPlaceholder, { 0: chatName })}
placeholder={intl.formatMessage(messages.inputPlaceholder, { 0: title })}
aria-controls={chatAreaId}
aria-label={intl.formatMessage(messages.inputLabel, { 0: chatTitle })}
aria-invalid={hasErrors ? 'true' : 'false'}

View File

@ -18,7 +18,7 @@ class ChatContainer extends PureComponent {
export default withTracker(() => {
const cleanScrollAndSendMessage = (message) => {
ChatService.updateScrollPosition(null);
ChatService.setUserSentMessage(true);
return ChatService.sendGroupMessage(message);
};

View File

@ -13,6 +13,7 @@
width: 100%;
position: relative;
margin-bottom: calc(-1 * var(--sm-padding-x));
margin-top: .2rem;
}
.wrapper {
@ -140,6 +141,7 @@
> span {
display: block;
margin-right: 0.05rem;
margin-left: 0.05rem;
line-height: var(--font-size-md);
}

View File

@ -280,7 +280,6 @@ class MessageList extends Component {
});
}}
onWheel={(e) => {
console.log('caiu aqui');
if (e.deltaY < 0) {
this.setState({
userScrolledBack: true,

View File

@ -28,11 +28,20 @@ const ScrollCollection = new Mongo.Collection(null);
const UnsentMessagesCollection = new Mongo.Collection(null);
export const UserSentMessageCollection = new Mongo.Collection(null);
// session for closed chat list
const CLOSED_CHAT_LIST_KEY = 'closedChatList';
const POLL_MESSAGE_PREFIX = 'bbb-published-poll-<br/>';
const setUserSentMessage = (bool) => {
UserSentMessageCollection.upsert(
{ userId: Auth.userID },
{ $set: { sent: bool } },
);
}
const getUser = userId => Users.findOne({ userId });
const getPrivateChatByUsers = userId => GroupChat
@ -47,6 +56,7 @@ const mapGroupMessage = (message) => {
content: message.content,
time: message.timestamp || message.time,
sender: null,
key: message.key
};
if (message.sender && message.sender.id !== SYSTEM_CHAT_TYPE) {
@ -75,7 +85,7 @@ const reduceGroupMessages = (previous, current) => {
time: current.timestamp,
color: current.color,
}];
if (!lastMessage || !currentMessage.chatId === PUBLIC_GROUP_CHAT_ID) {
if (!lastMessage) {
return previous.concat(currentMessage);
}
// Check if the last message is from the same user and time discrepancy
@ -95,6 +105,35 @@ const reduceGroupMessages = (previous, current) => {
return previous.concat(currentMessage);
};
const getChatMessages = (chatId) => {
return []
if (chatId === PUBLIC_CHAT_ID) {
return GroupChatMsg.find({
meetingId: Auth.meetingID,
chatId: PUBLIC_GROUP_CHAT_ID,
}, { sort: ['timestamp'] }).fetch();
}
const senderId = Auth.userID;
const privateChat = GroupChat.findOne({
meetingId: Auth.meetingID,
users: { $all: [chatId, senderId] },
access: PRIVATE_CHAT_TYPE,
});
if (privateChat) {
const {
chatId: id,
} = privateChat;
return GroupChatMsg.find({
meetingId: Auth.meetingID,
chatId: id,
}, { sort: ['timestamp'] }).fetch();
}
};
const reduceAndMapGroupMessages = messages => (messages
.reduce(reduceGroupMessages, []).map(mapGroupMessage));
@ -102,6 +141,7 @@ const reduceAndDontMapGroupMessages = messages => (messages
.reduce(reduceGroupMessages, []));
const getPublicGroupMessages = () => {
return [];
const publicGroupMessages = GroupChatMsg.find({
meetingId: Auth.meetingID,
chatId: PUBLIC_GROUP_CHAT_ID,
@ -110,6 +150,7 @@ const getPublicGroupMessages = () => {
};
const getPrivateGroupMessages = () => {
return [];
const chatID = Session.get('idChatOpen');
const senderId = Auth.userID;
@ -177,7 +218,11 @@ const lastReadMessageTime = (receiverID) => {
};
const sendGroupMessage = (message) => {
const chatID = Session.get('idChatOpen');
// TODO: Refactor to use chatId directly
const chatIdToSent = Session.get('idChatOpen') === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : Session.get('idChatOpen')
const chat = GroupChat.findOne({ chatId: chatIdToSent },
{ fields: { users: 1 } });
const chatID = Session.get('idChatOpen') === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : chat.users.filter(id => id !== Auth.userID)[0];
const isPublicChat = chatID === PUBLIC_CHAT_ID;
let destinationChatId = PUBLIC_GROUP_CHAT_ID;
@ -238,12 +283,11 @@ const updateUnreadMessage = (timestamp) => {
const clearPublicChatHistory = () => (makeCall('clearPublicChatHistory'));
const closePrivateChat = () => {
const chatID = Session.get('idChatOpen');
const closePrivateChat = (chatId) => {
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
if (_.indexOf(currentClosedChats, chatID) < 0) {
currentClosedChats.push(chatID);
if (_.indexOf(currentClosedChats, chatId) < 0) {
currentClosedChats.push(chatId);
Storage.setItem(CLOSED_CHAT_LIST_KEY, currentClosedChats);
}
@ -268,45 +312,27 @@ const htmlDecode = (input) => {
};
// Export the chat as [Hour:Min] user: message
const exportChat = (messageList) => {
const { welcomeProp } = getWelcomeProp();
const { loginTime } = Users.findOne({ userId: Auth.userID }, { fields: { loginTime: 1 } });
const { welcomeMsg } = welcomeProp;
const exportChat = (timeWindowList) => {
const messageList = timeWindowList.reduce( (acc, timeWindow) => [...acc, ...timeWindow.content], []);
messageList.sort((a, b) => a.time - b.time);
const clearMessage = messageList.filter(message => message.message === PUBLIC_CHAT_CLEAR);
const hasClearMessage = clearMessage.length;
if (!hasClearMessage || (hasClearMessage && clearMessage[0].timestamp < loginTime)) {
messageList.push({
timestamp: loginTime,
message: welcomeMsg,
type: SYSTEM_CHAT_TYPE,
sender: {
id: PUBLIC_CHAT_USER_ID,
name: ''
},
});
}
messageList.sort((a, b) => a.timestamp - b.timestamp);
return messageList.map((message) => {
const date = new Date(message.timestamp);
return messageList.map(message => {
const date = new Date(message.time);
const hour = date.getHours().toString().padStart(2, 0);
const min = date.getMinutes().toString().padStart(2, 0);
const hourMin = `[${hour}:${min}]`;
if (message.type === SYSTEM_CHAT_TYPE) {
return `${hourMin} ${message.message}`;
}
const userName = message.sender.id === PUBLIC_CHAT_USER_ID
const userName = message.id.endsWith('welcome-msg')
? ''
: `${message.sender.name} :`;
return `${hourMin} ${userName} ${htmlDecode(message.message)}`;
: `${message.name} :`;
return `${hourMin} ${userName} ${htmlDecode(message.text)}`;
}).join('\n');
};
}
const getAllMessages = (chatID) => {
return [];
const filter = {
'sender.id': { $ne: Auth.userID },
};
@ -335,9 +361,11 @@ const getLastMessageTimestampFromChatList = activeChats => activeChats
.reduce(maxNumberReducer, 0);
export default {
setUserSentMessage,
mapGroupMessage,
reduceAndMapGroupMessages,
reduceAndDontMapGroupMessages,
getChatMessages,
getPublicGroupMessages,
getPrivateGroupMessages,
getUser,

View File

@ -0,0 +1,316 @@
import React, { PureComponent } from 'react';
import { findDOMNode } from 'react-dom';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import Button from '/imports/ui/components/button/component';
import { List, AutoSizer,CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { styles } from './styles';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import TimeWindowChatItem from './time-window-chat-item/container';
const CHAT_CONFIG = Meteor.settings.public.chat;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const propTypes = {
scrollPosition: PropTypes.number,
chatId: PropTypes.string.isRequired,
hasUnreadMessages: PropTypes.bool.isRequired,
handleScrollUpdate: PropTypes.func.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
id: PropTypes.string.isRequired,
lastReadMessageTime: PropTypes.number,
};
const defaultProps = {
scrollPosition: null,
lastReadMessageTime: 0,
};
const intlMessages = defineMessages({
moreMessages: {
id: 'app.chat.moreMessages',
description: 'Chat message when the user has unread messages below the scroll',
},
emptyLogLabel: {
id: 'app.chat.emptyLogLabel',
description: 'aria-label used when chat log is empty',
},
});
class TimeWindowList extends PureComponent {
constructor(props) {
super(props);
this.cache = new CellMeasurerCache({
fixedWidth: true,
minHeight: 18,
keyMapper: (rowIndex, columnIndex) => {
const { timeWindowsValues } = this.props;
const timewindow = timeWindowsValues[rowIndex];
const key = timewindow.key;
const contentCount = timewindow.content.length;
return `${key}-${contentCount}`;
},
});
this.userScrolledBack = false;
this.handleScrollUpdate = _.debounce(this.handleScrollUpdate.bind(this), 150);
this.rowRender = this.rowRender.bind(this);
this.systemMessagesResized = {};
this.state = {
scrollArea: null,
shouldScrollToPosition: false,
scrollPosition: 0,
userScrolledBack: false,
lastMessage: {},
};
this.welcomeMessageIndex = -1;
this.listRef = null;
this.virualRef = null;
this.lastWidth = 0;
this.scrollInterval = null;
}
componentDidMount() {
// TODO: re-implement scroll to position using virtualized list
}
componentDidUpdate(prevProps) {
ChatLogger.debug('TimeWindowList::componentDidUpdate', {...this.props}, {...prevProps});
if (this.virualRef) {
if (this.virualRef.style.direction !== document.documentElement.dir) {
this.virualRef.style.direction = document.documentElement.dir;
}
}
const {
userSentMessage,
setUserSentMessage,
timeWindowsValues,
chatId,
} = this.props;
const {timeWindowsValues: prevTimeWindowsValues, chatId: prevChatId} = prevProps;
const prevTimeWindowsLength = prevTimeWindowsValues.length;
const timeWindowsValuesLength = timeWindowsValues.length;
const prevLastTimeWindow = prevTimeWindowsValues[prevTimeWindowsLength - 1];
const lastTimeWindow = timeWindowsValues[prevTimeWindowsLength - 1];
if ((lastTimeWindow && (prevLastTimeWindow?.content.length !== lastTimeWindow?.content.length))) {
if (this.listRef) {
this.cache.clear(timeWindowsValuesLength-1);
this.listRef.recomputeRowHeights(timeWindowsValuesLength-1);
}
}
if (lastTimeWindow && (chatId !== prevChatId)) {
this.listRef.recomputeGridSize();
}
if (userSentMessage && !prevProps.userSentMessage){
this.setState({
userScrolledBack: false,
}, ()=> setUserSentMessage(false));
}
}
componentWillUnmount() {
clearInterval(this.scrollInterval);
}
handleScrollUpdate(position, target) {
const {
handleScrollUpdate,
} = this.props;
if (position !== null && position + target?.offsetHeight === target?.scrollHeight) {
// I used one because the null value is used to notify that
// the user has sent a message and the message list should scroll to bottom
handleScrollUpdate(1);
return;
}
handleScrollUpdate(position || 1);
}
scrollTo(position = null) {
if (position) {
setTimeout(() => this.setState({
shouldScrollToPosition: true,
scrollPosition: position,
}), 200);
}
}
rowRender({
index,
parent,
style,
key,
}) {
const {
id,
timeWindowsValues,
dispatch,
chatId,
} = this.props;
const { scrollArea } = this.state;
const message = timeWindowsValues[index];
if (message.key === `${SYSTEM_CHAT_TYPE}-welcome-msg`) {
if (index !== this.welcomeMessageIndex) {
this.welcomeMessageIndex = index;
[500, 1000, 2000, 3000, 4000, 5000].forEach((i)=>{
setTimeout(() => {
if (this.listRef) {
this.cache.clear(index);
this.listRef.recomputeRowHeights(index);
}
}, i);
})
}
}
ChatLogger.debug('TimeWindowList::rowRender', this.props);
return (
<CellMeasurer
key={key}
cache={this.cache}
columnIndex={0}
parent={parent}
rowIndex={index}
>
<span
style={style}
key={`span-${key}-${index}`}
>
<TimeWindowChatItem
key={key}
message={message}
messageId={message.id}
chatAreaId={id}
scrollArea={scrollArea}
dispatch={dispatch}
chatId={chatId}
/>
</span>
</CellMeasurer>
);
}
renderUnreadNotification() {
const {
intl,
count,
} = this.props;
const { userScrolledBack } = this.state;
if (count && userScrolledBack) {
return (
<Button
aria-hidden="true"
className={styles.unreadButton}
color="primary"
size="sm"
key="unread-messages"
label={intl.formatMessage(intlMessages.moreMessages)}
onClick={()=> this.setState({
userScrolledBack: false,
})}
/>
);
}
return null;
}
render() {
const {
timeWindowsValues,
} = this.props;
const {
scrollArea,
userScrolledBack,
} = this.state;
ChatLogger.debug('TimeWindowList::render', {...this.props}, {...this.state}, new Date());
return (
[<div
onMouseDown={()=> {
this.setState({
userScrolledBack: true,
});
}}
onWheel={(e) => {
if (e.deltaY < 0) {
this.setState({
userScrolledBack: true,
});
this.userScrolledBack = true
}
}}
className={styles.messageListWrapper}
key="chat-list"
data-test="chatMessages"
ref={node => this.messageListWrapper = node}
>
<AutoSizer>
{({ height, width }) => {
if (width !== this.lastWidth) {
this.lastWidth = width;
this.cache.clearAll();
}
return (
<List
ref={(ref) => {
if (ref !== null) {
this.listRef = ref;
if (!scrollArea) {
this.setState({ scrollArea: findDOMNode(this.listRef) });
}
}
}}
isScrolling={true}
rowHeight={this.cache.rowHeight}
className={styles.messageList}
rowRenderer={this.rowRender}
rowCount={timeWindowsValues.length}
height={height}
width={width}
overscanRowCount={0}
deferredMeasurementCache={this.cache}
scrollToIndex={
!userScrolledBack ? timeWindowsValues.length - 1 : undefined
}
onScroll={({ clientHeight, scrollHeight, scrollTop })=> {
const scrollSize = scrollTop + clientHeight;
if (scrollSize >= scrollHeight) {
this.setState({
userScrolledBack: false,
});
}
}}
/>
);
}}
</AutoSizer>
</div>,
this.renderUnreadNotification()]
);
}
}
TimeWindowList.propTypes = propTypes;
TimeWindowList.defaultProps = defaultProps;
export default injectIntl(TimeWindowList);

View File

@ -0,0 +1,31 @@
import React, { PureComponent } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import TimeWindowList from './component';
import Auth from '/imports/ui/services/auth';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import ChatService, { UserSentMessageCollection } from '../service';
export default class TimeWindowListContainer extends PureComponent {
render() {
const { chatId } = this.props;
const hasUnreadMessages = ChatService.hasUnreadMessages(chatId);
const scrollPosition = ChatService.getScrollPosition(chatId);
const lastReadMessageTime = ChatService.lastReadMessageTime(chatId);
const userSentMessage = UserSentMessageCollection.findOne({ userId: Auth.userID, sent: true });
ChatLogger.debug('TimeWindowListContainer::render', { ...this.props }, new Date());
return (
<TimeWindowList
{
...{
...this.props,
hasUnreadMessages,
scrollPosition,
lastReadMessageTime,
handleScrollUpdate: ChatService.updateScrollPosition,
userSentMessage,
setUserSentMessage: ChatService.setUserSentMessage,
}
}
/>
);
}
}

View File

@ -0,0 +1,57 @@
@import "/imports/ui/stylesheets/mixins/_scrollable";
@import "/imports/ui/stylesheets/variables/placeholders";
.messageListWrapper {
display: flex;
flex-flow: column;
flex-grow: 1;
flex-shrink: 1;
position: relative;
overflow-x: hidden;
overflow-y: auto;
padding-left: var(--sm-padding-x);
margin-left: calc(-1 * var(--md-padding-x));
padding-right: var(--sm-padding-y);
margin-right: calc(-1 * var(--md-padding-y));
padding-bottom: var(--md-padding-x);
margin-bottom: calc(-1 * var(--md-padding-x));
z-index: 2;
[dir="rtl"] & {
padding-right: var(--md-padding-x);
margin-right: calc(-1 * var(--md-padding-x));
padding-left: var(--md-padding-y);
margin-left: calc(-1 * var(--md-padding-x));
}
}
.messageList {
@include scrollbox-vertical();
flex-flow: column;
flex-grow: 1;
flex-shrink: 1;
margin: 0 auto 0 0;
right: 0 var(--md-padding-x) 0 0;
padding-top: 0;
width: 100%;
outline-style: none;
[dir="rtl"] & {
margin: 0 0 0 auto;
padding: 0 0 0 var(--md-padding-x);
}
&:after {
content: "";
display: block;
height: var(--md-padding-x);
}
}
.unreadButton {
flex-shrink: 0;
width: 100%;
text-transform: uppercase;
margin-bottom: .25rem;
z-index: 3;
@extend %text-elipsis;
}

View File

@ -0,0 +1,255 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { FormattedTime, defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import Icon from '/imports/ui/components/icon/component';
import UserAvatar from '/imports/ui/components/user-avatar/component';
import cx from 'classnames';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import MessageChatItem from './message-chat-item/component';
import { styles } from './styles';
const CHAT_CONFIG = Meteor.settings.public.chat;
const CHAT_CLEAR_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_clear;
const propTypes = {
user: PropTypes.shape({
color: PropTypes.string,
isModerator: PropTypes.bool,
isOnline: PropTypes.bool,
name: PropTypes.string,
}),
messages: PropTypes.arrayOf(Object).isRequired,
time: PropTypes.number,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
scrollArea: PropTypes.instanceOf(Element),
chatAreaId: PropTypes.string.isRequired,
handleReadMessage: PropTypes.func.isRequired,
lastReadMessageTime: PropTypes.number,
};
const defaultProps = {
user: null,
scrollArea: null,
lastReadMessageTime: 0,
time: 0,
};
const intlMessages = defineMessages({
offline: {
id: 'app.chat.offline',
description: 'Offline',
},
pollResult: {
id: 'app.chat.pollResult',
description: 'used in place of user name who published poll to chat',
},
[CHAT_CLEAR_MESSAGE]: {
id: 'app.chat.clearPublicChatMessage',
description: 'message of when clear the public chat',
}
});
class TimeWindowChatItem extends PureComponent {
componentDidUpdate(prevProps, prevState) {
ChatLogger.debug('TimeWindowChatItem::componentDidUpdate::props', { ...this.props }, { ...prevProps });
ChatLogger.debug('TimeWindowChatItem::componentDidUpdate::state', { ...this.state }, { ...prevState });
}
componentWillMount() {
ChatLogger.debug('TimeWindowChatItem::componentWillMount::props', { ...this.props });
ChatLogger.debug('TimeWindowChatItem::componentWillMount::state', { ...this.state });
}
renderSystemMessage() {
const {
messages,
chatAreaId,
handleReadMessage,
messageKey,
intl,
} = this.props;
return (
<div className={styles.item} key={`time-window-chat-item-${messageKey}`}>
<div className={styles.messages}>
{messages.map(message => (
message.text !== ''
? (
<MessageChatItem
className={(message.id ? styles.systemMessage : styles.systemMessageNoBorder)}
key={message.id ? message.id : _.uniqueId('id-')}
text={intlMessages[message.text] ? intl.formatMessage(intlMessages[message.text]) : message.text }
time={message.time}
isSystemMessage={message.id ? true : false}
chatAreaId={chatAreaId}
handleReadMessage={handleReadMessage}
/>
) : null
))}
</div>
</div>
);
}
renderMessageItem() {
const {
user,
time,
chatAreaId,
scrollArea,
intl,
messages,
messageKey,
dispatch,
chatId,
read,
} = this.props;
if (messages && messages[0].text.includes('bbb-published-poll-<br/>')) {
return this.renderPollItem();
}
const dateTime = new Date(time);
const regEx = /<a[^>]+>/i;
ChatLogger.debug('TimeWindowChatItem::renderMessageItem', this.props);
const defaultAvatarString = user?.name?.toLowerCase().slice(0, 2) || " ";
return (
<div className={styles.item} key={`time-window-${messageKey}`}>
<div className={styles.wrapper}>
<div className={styles.avatarWrapper}>
<UserAvatar
className={styles.avatar}
color={user.color}
moderator={user.isModerator}
avatar={user.avatar}
>
{defaultAvatarString}
</UserAvatar>
</div>
<div className={styles.content}>
<div className={styles.meta}>
<div className={user.isOnline ? styles.name : styles.logout}>
<span>{user.name}</span>
{user.isOnline
? null
: (
<span className={styles.offline}>
{`(${intl.formatMessage(intlMessages.offline)})`}
</span>
)}
</div>
<time className={styles.time} dateTime={dateTime}>
<FormattedTime value={dateTime} />
</time>
</div>
<div className={styles.messages}>
{messages.map(message => (
<MessageChatItem
className={(regEx.test(message.text) ? styles.hyperlink : styles.message)}
key={message.id}
text={message.text}
time={message.time}
chatAreaId={chatAreaId}
dispatch={dispatch}
read={message.read}
handleReadMessage={(timestamp) => {
if (!read) {
dispatch({
type: 'last_read_message_timestamp_changed',
value: {
chatId,
timestamp,
},
});
}
}}
scrollArea={scrollArea}
/>
))}
</div>
</div>
</div>
</div>
);
}
renderPollItem() {
const {
user,
time,
intl,
isDefaultPoll,
messages,
scrollArea,
chatAreaId,
lastReadMessageTime,
handleReadMessage,
} = this.props;
const dateTime = new Date(time);
return messages ? (
<div className={styles.item} key={_.uniqueId('message-poll-item-')}>
<div className={styles.wrapper} ref={(ref) => { this.item = ref; }}>
<div className={styles.avatarWrapper}>
<UserAvatar
className={styles.avatar}
color={user.color}
moderator={user.isModerator}
>
{<Icon className={styles.isPoll} iconName="polling" />}
</UserAvatar>
</div>
<div className={styles.content}>
<div className={styles.meta}>
<div className={styles.name}>
<span>{intl.formatMessage(intlMessages.pollResult)}</span>
</div>
<time className={styles.time} dateTime={dateTime}>
<FormattedTime value={dateTime} />
</time>
</div>
<MessageChatItem
type="poll"
className={cx(styles.message, styles.pollWrapper)}
key={messages[0].id}
text={messages[0].text}
time={messages[0].time}
chatAreaId={chatAreaId}
lastReadMessageTime={lastReadMessageTime}
handleReadMessage={handleReadMessage}
scrollArea={scrollArea}
color={user.color}
isDefaultPoll={isDefaultPoll(messages[0].text.replace('bbb-published-poll-<br/>', ''))}
/>
</div>
</div>
</div>
) : null;
}
render() {
const {
user,
systemMessage,
} = this.props;
ChatLogger.debug('TimeWindowChatItem::render', {...this.props});
if (systemMessage) {
return this.renderSystemMessage();
}
return (
<div className={styles.item}>
{this.renderMessageItem()}
</div>
);
}
}
TimeWindowChatItem.propTypes = propTypes;
TimeWindowChatItem.defaultProps = defaultProps;
export default injectIntl(TimeWindowChatItem);

View File

@ -0,0 +1,47 @@
import React, { PureComponent } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import TimeWindowChatItem from './component';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import ChatService from '../../service';
const CHAT_CONFIG = Meteor.settings.public.chat;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const isDefaultPoll = (pollText) => {
const pollValue = pollText.replace(/<br\/>|[ :|%\n\d+]/g, '');
switch (pollValue) {
case 'A': case 'AB': case 'ABC': case 'ABCD':
case 'ABCDE': case 'YesNo': case 'TrueFalse':
return true;
default:
return false;
}
};
export default class TimeWindowChatItemContainer extends PureComponent {
render() {
ChatLogger.debug('TimeWindowChatItemContainer::render', { ...this.props });
const messages = this.props.message.content;
const user = this.props.message.sender;
const messageKey = this.props.message.key;
const time = this.props.message.time;
return (
<TimeWindowChatItem
{
...{
read: this.props.message.read,
messages,
isDefaultPoll,
user,
time,
systemMessage: this.props.messageId.startsWith(SYSTEM_CHAT_TYPE) || !user,
messageKey,
handleReadMessage: ChatService.updateUnreadMessage,
...this.props,
}
}
/>
);
}
}

View File

@ -0,0 +1,224 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import fastdom from 'fastdom';
import { defineMessages, injectIntl } from 'react-intl';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
const propTypes = {
text: PropTypes.string.isRequired,
time: PropTypes.number.isRequired,
lastReadMessageTime: PropTypes.number,
handleReadMessage: PropTypes.func.isRequired,
scrollArea: PropTypes.instanceOf(Element),
className: PropTypes.string.isRequired,
};
const defaultProps = {
lastReadMessageTime: 0,
scrollArea: undefined,
};
const eventsToBeBound = [
'scroll',
'resize',
];
const isElementInViewport = (el) => {
if (!el) return false;
const rect = el.getBoundingClientRect();
return (
rect.top >= 0
&& rect.left >= 0
&& rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)
&& rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
};
const intlMessages = defineMessages({
legendTitle: {
id: 'app.polling.pollingTitle',
description: 'heading for chat poll legend',
},
});
class MessageChatItem extends PureComponent {
constructor(props) {
super(props);
this.ticking = false;
this.handleMessageInViewport = _.debounce(this.handleMessageInViewport.bind(this), 50);
this.renderPollListItem = this.renderPollListItem.bind(this);
}
componentDidMount() {
this.listenToUnreadMessages();
}
componentDidUpdate(prevProps, prevState) {
ChatLogger.debug('MessageChatItem::componentDidUpdate::props', { ...this.props }, { ...prevProps });
ChatLogger.debug('MessageChatItem::componentDidUpdate::state', { ...this.state }, { ...prevState });
this.listenToUnreadMessages();
}
componentWillUnmount() {
// This was added 3 years ago, but never worked. Leaving it around in case someone returns
// and decides it needs to be fixed like the one in listenToUnreadMessages()
// if (!lastReadMessageTime > time) {
// return;
// }
ChatLogger.debug('MessageChatItem::componentWillUnmount', this.props);
this.removeScrollListeners();
}
addScrollListeners() {
const {
scrollArea,
} = this.props;
if (scrollArea) {
eventsToBeBound.forEach(
e => scrollArea.addEventListener(e, this.handleMessageInViewport),
);
}
}
handleMessageInViewport() {
if (!this.ticking) {
fastdom.measure(() => {
const node = this.text;
const {
handleReadMessage,
time,
read,
} = this.props;
if (read) {
this.removeScrollListeners();
return;
}
if (isElementInViewport(node) && !read) {
handleReadMessage(time);
this.removeScrollListeners();
}
this.ticking = false;
});
}
this.ticking = true;
}
removeScrollListeners() {
const {
scrollArea,
read,
} = this.props;
if (scrollArea && !read) {
eventsToBeBound.forEach(
e => scrollArea.removeEventListener(e, this.handleMessageInViewport),
);
}
}
// depending on whether the message is in viewport or not,
// either read it or attach a listener
listenToUnreadMessages() {
const {
handleReadMessage,
time,
read,
} = this.props;
if (read) {
return;
}
const node = this.text;
fastdom.measure(() => {
const {
read: newRead,
} = this.props;
// this function is called after so we need to get the updated lastReadMessageTime
if (newRead) {
return;
}
if (isElementInViewport(node)) { // no need to listen, the message is already in viewport
handleReadMessage(time);
} else {
this.addScrollListeners();
}
});
}
renderPollListItem() {
const {
intl,
text,
className,
color,
isDefaultPoll,
} = this.props;
const formatBoldBlack = s => s.bold().fontcolor('black');
let _text = text.replace('bbb-published-poll-<br/>', '');
if (!isDefaultPoll) {
const entries = _text.split('<br/>');
const options = [];
entries.map((e) => { options.push([e.slice(0, e.indexOf(':'))]); return e; });
options.map((o, idx) => {
_text = formatBoldBlack(_text.replace(o, idx + 1));
return _text;
});
_text += formatBoldBlack(`<br/><br/>${intl.formatMessage(intlMessages.legendTitle)}`);
options.map((o, idx) => { _text += `<br/>${idx + 1}: ${o}`; return _text; });
}
return (
<p
className={className}
style={{ borderLeft: `3px ${color} solid` }}
ref={(ref) => { this.text = ref; }}
dangerouslySetInnerHTML={{ __html: isDefaultPoll ? formatBoldBlack(_text) : _text }}
data-test="chatPollMessageText"
/>
);
}
render() {
const {
text,
type,
className,
isSystemMessage,
chatUserMessageItem,
} = this.props;
ChatLogger.debug('MessageChatItem::render', this.props);
if (type === 'poll') return this.renderPollListItem();
return (
<p
className={className}
ref={(ref) => { this.text = ref; }}
dangerouslySetInnerHTML={{ __html: text }}
data-test={isSystemMessage ? 'chatWelcomeMessageText' : chatUserMessageItem ? 'chatUserMessageText' : 'chatClearMessageText'}
/>
);
}
}
MessageChatItem.propTypes = propTypes;
MessageChatItem.defaultProps = defaultProps;
export default injectIntl(MessageChatItem);

View File

@ -0,0 +1,173 @@
@import "/imports/ui/stylesheets/variables/placeholders";
:root {
--systemMessage-background-color: #F9FBFC;
--systemMessage-border-color: #C5CDD4;
--systemMessage-font-color: var(--color-dark-grey);
--chat-poll-margin-sm: .5rem;
}
.item {
padding: calc(var(--line-height-computed) / 4) 0 calc(var(--line-height-computed) / 2) 0;
font-size: var(--font-size-base);
pointer-events: auto;
[dir="rtl"] & {
direction: rtl;
}
}
.wrapper {
display: flex;
flex-flow: row;
flex: 1;
position: relative;
margin:var(--border-size) 0 0 var(--border-size);
[dir="rtl"] & {
margin: var(--border-size) var(--border-size) 0 0;
}
}
.systemMessage {
background: var(--systemMessage-background-color);
border: 1px solid var(--systemMessage-border-color);
border-radius: var(--border-radius);
font-weight: var(--btn-font-weight);
padding: var(--font-size-base);
//margin-bottom: var(--line-height-computed);
color: var(--systemMessage-font-color);
margin-top: 0px;
margin-bottom: 0px;
}
.systemMessageNoBorder {
color: var(--systemMessage-font-color);
margin-top: 0px;
margin-bottom: 0px;
}
.avatarWrapper {
flex-basis: 2.25rem;
flex-shrink: 0;
flex-grow: 0;
margin: 0 calc(var(--line-height-computed) / 2) 0 0;
[dir="rtl"] & {
margin: 0 0 0 calc(var(--line-height-computed) / 2);
}
}
.avatar {
font-size: .85rem;
}
.content {
flex: 1;
display: flex;
flex-flow: column;
overflow-x: hidden;
width: calc(100% - 1.7rem);
}
.meta {
display: flex;
flex: 1;
flex-flow: row;
line-height: 1.35;
& + .message {
margin-top: 0;
}
}
.name,
.logout {
display: flex;
min-width: 0;
font-weight: 600;
position: relative;
:first-child {
@extend %text-elipsis;
}
}
.name {
color: var(--color-heading);
}
.logout {
text-transform: capitalize;
font-style: italic;
& > span {
text-align: right;
padding: 0 .1rem 0 0;
[dir="rtl"] & {
text-align: left;
padding: 0 0 0 .1rem;
}
}
}
.offline {
color: var(--color-gray-light);
font-weight: 100;
text-transform: lowercase;
font-style: italic;
font-size: 90%;
line-height: 1;
align-self: center;
}
.time {
flex-shrink: 0;
flex-grow: 0;
flex-basis: 3.5rem;
color: var(--color-gray-light);
text-transform: uppercase;
font-size: 75%;
margin: 0 0 0 calc(var(--line-height-computed) / 2);
[dir="rtl"] & {
margin: 0 calc(var(--line-height-computed) / 2) 0 0;
}
> span {
vertical-align: sub;
}
}
.messages {
> .message:first-child {
margin-top: calc(var(--line-height-computed) / 4);
}
}
.message, .hyperlink {
flex: 1;
margin-top: calc(var(--line-height-computed) / 3);
margin-bottom: 0;
color: var(--color-text);
word-wrap: break-word;
}
.hyperlink {
a {
color: var(--color-primary);
}
}
.isPoll {
bottom: var(--border-size-large);
}
.pollWrapper {
background: var(--systemMessage-background-color);
border: solid 1px var(--color-gray-lighter);
border-radius: var(--border-radius);
padding: var(--chat-poll-margin-sm);
padding-left: 1rem;
margin-top: var(--chat-poll-margin-sm) !important;
}

View File

@ -0,0 +1,53 @@
import { useContext, useEffect } from 'react';
import _ from 'lodash';
import { ChatContext, ACTIONS } from './context';
import { UsersContext } from '../users-context/context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
let usersData = {};
let messageQueue = [];
const Adapter = () => {
const usingChatContext = useContext(ChatContext);
const { dispatch } = usingChatContext;
const usingUsersContext = useContext(UsersContext);
const { users } = usingUsersContext;
ChatLogger.trace('chatAdapter::body::users', users);
useEffect(() => {
usersData = users;
}, [usingUsersContext]);
useEffect(() => {
// TODO: listen to websocket message to avoid full list comparsion
const throttledDispatch = _.throttle(() => {
const dispatchedMessageQueue = [...messageQueue];
messageQueue = [];
dispatch({
type: ACTIONS.ADDED,
value: dispatchedMessageQueue,
});
}, 1000, { trailing: true, leading: true });
Meteor.connection._stream.socket.addEventListener('message', (msg) => {
if (msg.data.indexOf('{"msg":"added","collection":"group-chat-msg"') != -1) {
const parsedMsg = JSON.parse(msg.data);
if (parsedMsg.msg === 'added') {
messageQueue.push({
msg: parsedMsg.fields,
senderData: usersData[parsedMsg.fields.sender.id],
});
throttledDispatch();
}
}
if (msg.data.indexOf('{"msg":"removed","collection":"group-chat-msg"') != -1) {
dispatch({
type: ACTIONS.REMOVED,
});
}
});
}, []);
return null;
};
export default Adapter;

View File

@ -0,0 +1,337 @@
import React, {
createContext,
useReducer,
} from 'react';
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import Storage from '/imports/ui/services/storage/session';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import { _ } from 'lodash';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const CLOSED_CHAT_LIST_KEY = 'closedChatList';
export const ACTIONS = {
TEST: 'test',
ADDED: 'added',
CHANGED: 'changed',
REMOVED: 'removed',
USER_STATUS_CHANGED: 'user_status_changed',
LAST_READ_MESSAGE_TIMESTAMP_CHANGED: 'last_read_message_timestamp_changed',
INIT: 'initial_structure',
};
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
export const getGroupingTime = () => Meteor.settings.public.chat.grouping_messages_window;
export const getGroupChatId = () => Meteor.settings.public.chat.public_group_id;
export const getLoginTime = () => (Users.findOne({ userId: Auth.userID }) || {}).loginTime || 0;
const generateTimeWindow = (timestamp) => {
const groupingTime = getGroupingTime();
dateInMilliseconds = Math.floor(timestamp);
groupIndex = Math.floor(dateInMilliseconds / groupingTime)
date = groupIndex * 30000;
return date;
}
export const ChatContext = createContext();
const generateStateWithNewMessage = ({ msg, senderData }, state) => {
const timeWindow = generateTimeWindow(msg.timestamp);
const userId = msg.sender.id;
const keyName = userId + '-' + timeWindow;
const msgBuilder = ({msg, senderData}, chat) => {
const msgTimewindow = generateTimeWindow(msg.timestamp);
const key = msg.sender.id + '-' + msgTimewindow;
const chatIndex = chat?.chatIndexes[key];
const {
_id,
...restMsg
} = msg;
const senderInfo = {
id: senderData?.userId || msg.sender.id,
avatar: senderData?.avatar,
color: senderData?.color ,
isModerator: senderData?.role === ROLE_MODERATOR, // TODO: get isModerator from message
name: senderData?.name || msg.sender.name,
isOnline: !!senderData,
};
const indexValue = chatIndex ? (chatIndex + 1) : 1;
const messageKey = key + '-' + indexValue;
const tempGroupMessage = {
[messageKey]: {
...restMsg,
key: messageKey,
lastTimestamp: msg.timestamp,
read: msg.chatId === PUBLIC_CHAT_KEY && msg.timestamp <= getLoginTime() ? true : false,
content: [
{ id: msg.id, name: msg.sender.name, text: msg.message, time: msg.timestamp },
],
sender: senderInfo,
}
};
return [tempGroupMessage, msg.sender, indexValue];
};
let stateMessages = state[msg.chatId];
if (!stateMessages) {
if (msg.chatId === getGroupChatId()) {
state[msg.chatId] = {
count: 0,
chatIndexes: {},
preJoinMessages: {},
posJoinMessages: {},
unreadTimeWindows: new Set(),
unreadCount: 0,
};
} else {
state[msg.chatId] = {
count: 0,
lastSender: '',
chatIndexes: {},
messageGroups: {},
unreadTimeWindows: new Set(),
unreadCount: 0,
};
stateMessages = state[msg.chatId];
}
stateMessages = state[msg.chatId];
}
const forPublicChat = msg.timestamp < getLoginTime() ? stateMessages.preJoinMessages : stateMessages.posJoinMessages;
const forPrivateChat = stateMessages.messageGroups;
const messageGroups = msg.chatId === getGroupChatId() ? forPublicChat : forPrivateChat;
const timewindowIndex = stateMessages.chatIndexes[keyName];
const groupMessage = messageGroups[keyName + '-' + timewindowIndex];
if (!groupMessage || (groupMessage && groupMessage.sender.id !== stateMessages.lastSender.id)) {
const [tempGroupMessage, sender, newIndex] = msgBuilder({msg, senderData}, stateMessages);
stateMessages.lastSender = sender;
stateMessages.chatIndexes[keyName] = newIndex;
stateMessages.lastTimewindow = keyName + '-' + newIndex;
ChatLogger.trace('ChatContext::formatMsg::msgBuilder::tempGroupMessage', tempGroupMessage);
const messageGroupsKeys = Object.keys(tempGroupMessage);
messageGroupsKeys.forEach(key => {
messageGroups[key] = tempGroupMessage[key];
const message = tempGroupMessage[key];
if (message.sender.id !== Auth.userID && !message.id.startsWith(SYSTEM_CHAT_TYPE)) {
stateMessages.unreadTimeWindows.add(key);
}
});
} else {
if (groupMessage) {
if (groupMessage.sender.id === stateMessages.lastSender.id) {
const previousMessage = msg.timestamp <= getLoginTime();
const timeWindowKey = keyName + '-' + stateMessages.chatIndexes[keyName];
messageGroups[timeWindowKey] = {
...groupMessage,
lastTimestamp: msg.timestamp,
read: previousMessage ? true : false,
content: [
...groupMessage.content,
{ id: msg.id, name: groupMessage.sender.name, text: msg.message, time: msg.timestamp }
],
};
if (!previousMessage && groupMessage.sender.id !== Auth.userID) {
stateMessages.unreadTimeWindows.add(timeWindowKey);
}
}
}
}
return state;
}
const reducer = (state, action) => {
switch (action.type) {
case ACTIONS.TEST: {
ChatLogger.debug(ACTIONS.TEST);
return {
...state,
...action.value,
};
}
case ACTIONS.ADDED: {
ChatLogger.debug(ACTIONS.ADDED);
const batchMsgs = action.value;
const closedChatsToOpen = new Set();
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
const loginTime = getLoginTime();
const newState = batchMsgs.reduce((acc, i)=> {
const message = i.msg;
const chatId = message.chatId;
if (
chatId !== PUBLIC_GROUP_CHAT_KEY
&& message.timestamp > loginTime
&& currentClosedChats.includes(chatId) ){
closedChatsToOpen.add(chatId)
}
return generateStateWithNewMessage(i, acc);
}, state);
if (closedChatsToOpen.size) {
const closedChats = currentClosedChats.filter(chatId => !closedChatsToOpen.has(chatId));
Storage.setItem(CLOSED_CHAT_LIST_KEY, closedChats);
}
// const newState = generateStateWithNewMessage(action.value, state);
return {...newState};
}
case ACTIONS.CHANGED: {
return {
...state,
...action.value,
};
}
case ACTIONS.REMOVED: {
ChatLogger.debug(ACTIONS.REMOVED);
if (state[PUBLIC_GROUP_CHAT_KEY]){
state[PUBLIC_GROUP_CHAT_KEY] = {
count: 0,
lastSender: '',
chatIndexes: {},
preJoinMessages: {},
posJoinMessages: {},
unreadTimeWindows: new Set(),
unreadCount: 0,
};
}
return state;
}
case ACTIONS.USER_STATUS_CHANGED: {
ChatLogger.debug(ACTIONS.USER_STATUS_CHANGED);
const newState = {
...state,
};
const affectedChats = [];
// select all groups of users
Object.keys(newState).forEach(chatId => {
const affectedGroups = Object.keys(newState[chatId])
.filter(groupId => groupId.startsWith(action.value.userId));
if (affectedGroups.length) {
affectedChats[chatId] = affectedGroups;
}
});
//Apply change to new state
Object.keys(affectedChats).forEach((chatId) => {
// force new reference
newState[chatId] = {
...newState[chatId]
};
//Apply change
affectedChats[chatId].forEach(groupId => {
newState[chatId][groupId] = {
...newState[chatId][groupId] //TODO: sort by time (for incromental loading)
};
newState[chatId][groupId].status = action.value.status;
});
});
return newState
}
case ACTIONS.LAST_READ_MESSAGE_TIMESTAMP_CHANGED: {
ChatLogger.debug(ACTIONS.LAST_READ_MESSAGE_TIMESTAMP_CHANGED);
const { timestamp, chatId } = action.value;
const newState = {
...state,
};
const selectedChatId = chatId === PUBLIC_CHAT_KEY ? PUBLIC_GROUP_CHAT_KEY : chatId;
const chat = state[selectedChatId];
['posJoinMessages','preJoinMessages','messageGroups'].forEach( messageGroupName => {
const messageGroup = chat[messageGroupName];
if (messageGroup){
const timeWindowsids = Object.keys(messageGroup);
timeWindowsids.forEach( timeWindowId => {
const timeWindow = messageGroup[timeWindowId];
if(timeWindow) {
if (!timeWindow.read) {
if (timeWindow.lastTimestamp <= timestamp){
newState[selectedChatId].unreadTimeWindows.delete(timeWindowId);
newState[selectedChatId][messageGroupName][timeWindowId] = {
...timeWindow,
read: true,
};
newState[selectedChatId] = {
...newState[selectedChatId],
};
newState[selectedChatId][messageGroupName] = {
...newState[selectedChatId][messageGroupName],
};
newState[chatId === PUBLIC_CHAT_KEY ? PUBLIC_GROUP_CHAT_KEY : chatId][messageGroupName][timeWindowId] = {
...newState[selectedChatId][messageGroupName][timeWindowId],
};
}
}
}
});
}
});
return newState;
}
case ACTIONS.INIT: {
ChatLogger.debug(ACTIONS.INIT);
const { chatId } = action;
const newState = { ...state };
if (!newState[chatId]){
newState[chatId] = {
count: 0,
lastSender: '',
chatIndexes: {},
messageGroups: {},
unreadTimeWindows: new Set(),
unreadCount: 0,
};
}
return state;
}
default: {
throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
}
}
};
export const ChatContextProvider = (props) => {
const [chatContextState, chatContextDispatch] = useReducer(reducer, {});
ChatLogger.debug('dispatch', chatContextDispatch);
return (
<ChatContext.Provider value={
{
dispatch: chatContextDispatch,
chats: chatContextState,
...props,
}
}
>
{props.children}
</ChatContext.Provider>
);
}
export const ContextConsumer = Component => props => (
<ChatContext.Consumer>
{contexts => <Component {...props} {...contexts} />}
</ChatContext.Consumer>
);
export default {
ContextConsumer,
ChatContextProvider,
}

View File

@ -0,0 +1,40 @@
import React, { useContext, useEffect } from 'react';
import GroupChat from '/imports/api/group-chat';
import { GroupChatContext, ACTIONS } from './context';
const Adapter = () => {
const usingGroupChatContext = useContext(GroupChatContext);
const { dispatch } = usingGroupChatContext;
useEffect(() => {
const alreadyDispatched = new Set();
const notDispatchedCount = { count: 100 };
// TODO: listen to websocket message to avoid full list comparsion
const diffAndDispatch = () => {
setTimeout(() => {
const groupChatCursor = GroupChat.find({}, { reactive: false }).fetch();
const notDispatched = groupChatCursor.filter(objMsg => !alreadyDispatched.has(objMsg._id));
notDispatchedCount.count = notDispatched.length;
notDispatched.forEach((groupChat) => {
dispatch({
type: ACTIONS.ADDED,
value: {
groupChat,
},
});
alreadyDispatched.add(groupChat._id);
});
diffAndDispatch();
}, notDispatchedCount.count >= 10 ? 1000 : 500);
};
diffAndDispatch();
}, []);
return null;
};
export default Adapter;

View File

@ -0,0 +1,76 @@
import React, {
createContext,
useReducer,
} from 'react';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
export const ACTIONS = {
TEST: 'test',
ADDED: 'added',
CHANGED: 'changed',
REMOVED: 'removed',
};
export const GroupChatContext = createContext();
const reducer = (state, action) => {
switch (action.type) {
case ACTIONS.TEST: {
return {
...state,
...action.value,
};
}
case ACTIONS.ADDED:
case ACTIONS.CHANGED: {
ChatLogger.debug('GroupChatContextProvider::reducer::added', { ...action });
const { groupChat } = action.value;
const newState = {
...state,
[groupChat.chatId]: {
...groupChat,
},
};
return newState;
}
case ACTIONS.REMOVED: {
ChatLogger.debug('GroupChatContextProvider::reducer::removed', { ...action });
return state;
}
default: {
throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
}
}
};
export const GroupChatContextProvider = (props) => {
const [groupChatContextState, groupChatContextDispatch] = useReducer(reducer, {});
ChatLogger.debug('UsersContextProvider::groupChatContextState', groupChatContextState);
return (
<GroupChatContext.Provider value={
{
...props,
dispatch: groupChatContextDispatch,
groupChat: { ...groupChatContextState },
}
}
>
{props.children}
</GroupChatContext.Provider>
);
};
export const GroupChatContextConsumer = Component => props => (
<GroupChatContext.Consumer>
{contexts => <Component {...props} {...contexts} />}
</GroupChatContext.Consumer>
);
export default {
GroupChatContextConsumer,
GroupChatContextProvider,
};

View File

@ -0,0 +1,43 @@
import { useContext, useEffect } from 'react';
import Users from '/imports/api/users';
import { UsersContext, ACTIONS } from './context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
const Adapter = () => {
const usingUsersContext = useContext(UsersContext);
const { dispatch } = usingUsersContext;
useEffect(() => {
const usersCursor = Users.find({}, { sort: { timestamp: 1 } });
usersCursor.observe({
added: (obj) => {
ChatLogger.debug("usersAdapter::observe::added", obj);
dispatch({
type: ACTIONS.ADDED,
value: {
user: obj,
},
});
},
changed: (obj) => {
dispatch({
type: ACTIONS.CHANGED,
value: {
user: obj,
},
});
},
removed: (obj) => {
dispatch({
type: ACTIONS.REMOVED,
value: {
user: obj,
},
});
},
});
}, []);
return null;
};
export default Adapter;

View File

@ -0,0 +1,83 @@
import React, {
createContext,
useReducer,
} from 'react';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
export const ACTIONS = {
TEST: 'test',
ADDED: 'added',
CHANGED: 'changed',
REMOVED: 'removed',
};
export const UsersContext = createContext();
const reducer = (state, action) => {
switch (action.type) {
case ACTIONS.TEST: {
return {
...state,
...action.value,
};
}
case ACTIONS.ADDED:
case ACTIONS.CHANGED: {
ChatLogger.debug('UsersContextProvider::reducer::added', { ...action });
const { user } = action.value;
const newState = {
...state,
[user.userId]: {
...user,
},
};
return newState;
}
case ACTIONS.REMOVED: {
ChatLogger.debug('UsersContextProvider::reducer::removed', { ...action });
const { user } = action.value;
if (state[user.userId]) {
const newState = { ...state };
delete newState[user.userId];
return newState;
}
return state;
}
default: {
throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
}
}
};
export const UsersContextProvider = (props) => {
const [usersContextState, usersContextDispatch] = useReducer(reducer, {});
ChatLogger.debug('UsersContextProvider::usersContextState', usersContextState);
return (
<UsersContext.Provider value={
{
...props,
dispatch: usersContextDispatch,
users: { ...usersContextState },
}
}
>
{props.children}
</UsersContext.Provider>
);
};
export const UsersContextConsumer = Component => props => (
<UsersContext.Consumer>
{contexts => <Component {...props} {...contexts} />}
</UsersContext.Consumer>
);
export default {
UsersContextConsumer,
UsersContextProvider,
};

View File

@ -0,0 +1,18 @@
import React from 'react';
import { ChatContextProvider } from '/imports/ui/components/components-data/chat-context/context';
import { UsersContextProvider } from '/imports/ui/components/components-data/users-context/context';
import { GroupChatContextProvider } from '/imports/ui/components/components-data/group-chat-context/context';
const providersList = [
ChatContextProvider,
GroupChatContextProvider,
UsersContextProvider,
];
const ContextProvidersComponent = props => providersList.reduce((acc, Component) => (
<Component>
{acc}
</Component>), props.children);
export default ContextProvidersComponent;

View File

@ -8,6 +8,7 @@ import Button from '/imports/ui/components/button/component';
import Toggle from '/imports/ui/components/switch/component';
import Storage from '/imports/ui/services/storage/session';
import { withLayoutConsumer } from '/imports/ui/components/layout/context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
const intlMessages = defineMessages({
modalClose: {
@ -49,6 +50,7 @@ class DebugWindow extends Component {
this.state = {
showDebugWindow: false,
logLevel: ChatLogger.getLogLevel(),
};
}
@ -87,7 +89,8 @@ class DebugWindow extends Component {
}
render() {
const { showDebugWindow } = this.state;
const { showDebugWindow, logLevel } = this.state;
const chatLoggerLevelsNames = Object.keys(ChatLogger.levels);
if (!DEBUG_WINDOW_ENABLED || !showDebugWindow) return false;
@ -191,6 +194,41 @@ class DebugWindow extends Component {
</div>
</div>
</div>
<div className={styles.row}>
<div className={styles.cell}>
testando o chatLogger levels:
</div>
<div className={styles.cell}>
<div className={styles.cellContent}>
<select
style={{ marginRight: '1rem' }}
onChange={(ev) => {
this.setState({
logLevel: ev.target.value,
});
}}
>
{
chatLoggerLevelsNames.map((i, index) => {
return (<option key={`${i}-${index}`}>{i}</option>);
})
}
</select>
<button
type="button"
disabled={logLevel === ChatLogger.getLogLevel()}
onClick={() => {
ChatLogger.setLogLevel(logLevel);
this.setState({
logLevel: ChatLogger.getLogLevel(),
});
}}
>
Aplicar
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -41,7 +41,8 @@ export default withTracker(() => {
}
const checkUnreadMessages = () => {
const activeChats = userListService.getActiveChats();
const activeChats = [];
// userListService.getActiveChats()
const hasUnreadMessages = activeChats
.filter(chat => chat.userId !== Session.get('idChatOpen'))
.some(chat => chat.unreadCounter > 0);

View File

@ -70,26 +70,6 @@ export default withTracker(() => {
subscriptionsHandlers.push(Meteor.subscribe('breakouts', currentUser.role, subscriptionErrorHandler));
}
let groupChatMessageHandler = {};
if (CHAT_ENABLED) {
const chats = GroupChat.find({
$or: [
{
meetingId,
access: PUBLIC_CHAT_TYPE,
chatId: { $ne: PUBLIC_GROUP_CHAT_ID },
},
{ meetingId, users: { $all: [requesterUserId] } },
],
}).fetch();
const chatIds = chats.map(chat => chat.chatId);
groupChatMessageHandler = Meteor.subscribe('group-chat-msg', chatIds, subscriptionErrorHandler);
subscriptionsHandlers.push(groupChatMessageHandler);
}
const annotationsHandler = Meteor.subscribe('annotations', {
onReady: () => {
const activeTextShapeId = AnnotationsTextService.activeTextShapeId();
@ -108,6 +88,23 @@ export default withTracker(() => {
subscriptionsHandlers = subscriptionsHandlers.filter(obj => obj);
const ready = subscriptionsHandlers.every(handler => handler.ready());
let groupChatMessageHandler = {};
if (CHAT_ENABLED && ready) {
const chats = GroupChat.find({
$or: [
{
meetingId,
access: PUBLIC_CHAT_TYPE,
chatId: { $ne: PUBLIC_GROUP_CHAT_ID },
},
{ meetingId, users: { $all: [requesterUserId] } },
],
}).fetch();
const chatIds = chats.map(chat => chat.chatId);
groupChatMessageHandler = Meteor.subscribe('group-chat-msg', chatIds, subscriptionErrorHandler);
}
return {
subscriptionsReady: ready,

View File

@ -1,14 +1,25 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { defineMessages, injectIntl } from 'react-intl';
import { Session } from 'meteor/session';
import _ from 'lodash';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import { styles } from './styles';
import ChatAvatar from './chat-avatar/component';
import ChatIcon from './chat-icon/component';
import ChatUnreadCounter from './chat-unread-messages/component';
const DEBOUNCE_TIME = 1000;
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
let globalAppplyStateToProps = ()=>{};
const throttledFunc = _.debounce(() => {
globalAppplyStateToProps();
}, DEBOUNCE_TIME, { trailing: true, leading: false });
const intlMessages = defineMessages({
titlePublic: {
id: 'app.chat.titlePublic',
@ -71,10 +82,29 @@ const ChatListItem = (props) => {
chatPanelOpen,
} = props;
const isCurrentChat = chat.userId === activeChatId && chatPanelOpen;
const linkClasses = {};
linkClasses[styles.active] = isCurrentChat;
const [stateUreadCount, setStateUreadCount] = useState(0);
if (chat.unreadCounter !== stateUreadCount && (stateUreadCount < chat.unreadCounter)) {
globalAppplyStateToProps = () => {
setStateUreadCount(chat.unreadCounter);
};
throttledFunc();
} else if (chat.unreadCounter !== stateUreadCount && (stateUreadCount > chat.unreadCounter)) {
setStateUreadCount(chat.unreadCounter);
}
useEffect(() => {
if (chat.userId !== PUBLIC_CHAT_KEY && chat.userId === activeChatId) {
Session.set('idChatOpen', chat.chatId);
}
}, [activeChatId]);
return (
<div
data-test="chatButton"
@ -83,7 +113,7 @@ const ChatListItem = (props) => {
aria-expanded={isCurrentChat}
tabIndex={tabIndex}
accessKey={isPublicChat(chat) ? TOGGLE_CHAT_PUB_AK : null}
onClick={() => handleClickToggleChat(chat.userId)}
onClick={() => handleClickToggleChat(chat.chatId)}
id="chat-toggle-button"
aria-label={isPublicChat(chat) ? intl.formatMessage(intlMessages.titlePublic) : chat.name}
>
@ -110,10 +140,10 @@ const ChatListItem = (props) => {
</span>
) : null}
</div>
{(chat.unreadCounter > 0)
{(stateUreadCount > 0)
? (
<ChatUnreadCounter
counter={chat.unreadCounter}
counter={stateUreadCount}
/>
)
: null}

View File

@ -7,7 +7,6 @@ import CustomLogo from './custom-logo/component';
import UserContentContainer from './user-list-content/container';
const propTypes = {
activeChats: PropTypes.arrayOf(String).isRequired,
compact: PropTypes.bool,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
@ -28,7 +27,6 @@ class UserList extends PureComponent {
render() {
const {
intl,
activeChats,
compact,
setEmojiStatus,
isPublicChat,
@ -50,7 +48,6 @@ class UserList extends PureComponent {
{<UserContentContainer
{...{
intl,
activeChats,
compact,
setEmojiStatus,
isPublicChat,

View File

@ -6,7 +6,6 @@ import Service from './service';
import UserList from './component';
const propTypes = {
activeChats: PropTypes.arrayOf(String).isRequired,
isPublicChat: PropTypes.func.isRequired,
setEmojiStatus: PropTypes.func.isRequired,
roving: PropTypes.func.isRequired,
@ -17,9 +16,8 @@ const UserListContainer = props => <UserList {...props} />;
UserListContainer.propTypes = propTypes;
export default withTracker(({ chatID, compact }) => ({
export default withTracker(({ compact }) => ({
hasBreakoutRoom: Service.hasBreakoutRoom(),
activeChats: Service.getActiveChats(chatID),
isPublicChat: Service.isPublicChat,
setEmojiStatus: Service.setEmojiStatus,
roving: Service.roving,

View File

@ -1,11 +1,9 @@
import Users from '/imports/api/users';
import VoiceUsers from '/imports/api/voice-users';
import GroupChat from '/imports/api/group-chat';
import { GroupChatMsg } from '/imports/api/group-chat-msg';
import Breakouts from '/imports/api/breakouts/';
import Meetings from '/imports/api/meetings';
import Auth from '/imports/ui/services/auth';
import UnreadMessages from '/imports/ui/services/unread-messages';
import Storage from '/imports/ui/services/storage/session';
import { EMOJI_STATUSES } from '/imports/utils/statuses';
import { makeCall } from '/imports/ui/services/api';
@ -216,82 +214,76 @@ const hasBreakoutRoom = () => Breakouts.find({ parentMeetingId: Auth.meetingID }
const isMe = userId => userId === Auth.userID;
const getActiveChats = (chatID) => {
const privateChat = GroupChat
.find({ users: { $all: [Auth.userID] } })
.fetch()
.map(chat => chat.chatId);
const filter = {
chatId: { $ne: PUBLIC_GROUP_CHAT_ID },
};
const getActiveChats = ({ groupChatsMessages, groupChats, users }) => {
if (privateChat) {
filter.chatId = { $in: privateChat };
}
if (_.isEmpty(groupChats) || _.isEmpty(users)) return [];
const chatIds = Object.keys(groupChats);
const lastTimeWindows = chatIds.reduce((acc, chatId) => {
const chat = groupChatsMessages[chatId];
const lastTimewindowKey = chat?.lastTimewindow;
const lastTimeWindow = lastTimewindowKey?.split('-')[1];
return {
...acc,
chatId: lastTimeWindow,
}
}, {});
let activeChats = GroupChatMsg
.find(filter)
.fetch();
const idsWithTimeStamp = {};
activeChats.map((chat) => {
idsWithTimeStamp[`${chat.sender.id}`] = chat.timestamp;
chatIds.sort((a,b) => {
if (a === PUBLIC_GROUP_CHAT_ID) {
return -1;
}
if (lastTimeWindows[a] === lastTimeWindows[b]){
return 0;
}
return 1;
});
const chatInfo = chatIds.map((chatId) => {
const contextChat = groupChatsMessages[chatId];
const isPublicChat = chatId === PUBLIC_GROUP_CHAT_ID;
let unreadMessagesCount = 0;
if (contextChat) {
const unreadTimewindows = contextChat.unreadTimeWindows;
for (const unreadTimeWindowId of unreadTimewindows) {
const timeWindow = (isPublicChat
? contextChat.preJoinMessages[unreadTimeWindowId] || contextChat.posJoinMessages[unreadTimeWindowId]
: contextChat.messageGroups[unreadTimeWindowId]);
unreadMessagesCount += timeWindow.content.length;
}
}
activeChats = activeChats.map(mapActiveChats);
if (chatID) {
activeChats.push(chatID);
}
activeChats = _.uniqBy(_.compact(activeChats), 'id');
activeChats = activeChats.map(({ id, name }) => {
const user = Users.findOne({ userId: id }, { fields: { color: 1, role: 1 } });
if (chatId !== PUBLIC_GROUP_CHAT_ID) {
const groupChatsParticipants = groupChats[chatId].participants;
const otherParticipant = groupChatsParticipants.filter((user)=> user.id !== Auth.userID)[0];
const user = users[otherParticipant.id];
return {
color: user?.color || '#7b1fa2',
isModerator: user?.role === ROLE_MODERATOR,
lastActivity: idsWithTimeStamp[id],
name,
unreadCounter: UnreadMessages.count(id),
userId: id,
name: user?.name || otherParticipant.name,
chatId,
unreadCounter: unreadMessagesCount,
userId: user?.userId || otherParticipant.id,
};
}
return {
userId: 'public',
name: 'Public Chat',
icon: 'group_chat',
chatId: 'public',
unreadCounter: unreadMessagesCount,
};
});
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
const filteredChatList = [];
return chatInfo.filter(chat => !currentClosedChats.includes(chat.chatId));
}
activeChats.forEach((op) => {
// When a new private chat message is received, ensure the conversation view is restored.
if (op.unreadCounter > 0) {
if (_.indexOf(currentClosedChats, op.userId) > -1) {
Storage.setItem(CLOSED_CHAT_LIST_KEY, _.without(currentClosedChats, op.userId));
}
}
// Compare activeChats with session and push it into filteredChatList
// if one of the activeChat is not in session.
// It will pass to activeChats.
if (_.indexOf(currentClosedChats, op.userId) < 0) {
filteredChatList.push(op);
}
});
activeChats = filteredChatList;
activeChats.push({
userId: 'public',
name: 'Public Chat',
icon: 'group_chat',
unreadCounter: UnreadMessages.count(PUBLIC_GROUP_CHAT_ID),
});
return activeChats
.sort(sortChats);
};
const isVoiceOnlyUser = userId => userId.toString().startsWith('v_');
@ -536,8 +528,14 @@ const hasPrivateChatBetweenUsers = (senderId, receiverId) => GroupChat
.findOne({ users: { $all: [receiverId, senderId] } });
const getGroupChatPrivate = (senderUserId, receiver) => {
if (!hasPrivateChatBetweenUsers(senderUserId, receiver.userId)) {
const chat = hasPrivateChatBetweenUsers(senderUserId, receiver.userId);
if (!chat) {
makeCall('createGroupChat', receiver);
} else {
const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY);
if (_.indexOf(currentClosedChats, chat.chatId) > -1) {
Storage.setItem(CLOSED_CHAT_LIST_KEY, _.without(currentClosedChats, chat.chatId));
}
}
};

View File

@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { styles } from './styles';
import UserParticipantsContainer from './user-participants/container';
import UserMessages from './user-messages/component';
import UserMessages from './user-messages/container';
import UserNotesContainer from './user-notes/container';
import UserCaptionsContainer from './user-captions/container';
import WaitingUsers from './waiting-users/component';
@ -10,7 +10,6 @@ import UserPolls from './user-polls/component';
import BreakoutRoomItem from './breakout-room/component';
const propTypes = {
activeChats: PropTypes.arrayOf(String).isRequired,
compact: PropTypes.bool,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
@ -39,12 +38,12 @@ class UserContent extends PureComponent {
setEmojiStatus,
roving,
isPublicChat,
activeChats,
pollIsOpen,
forcePollOpen,
hasBreakoutRoom,
pendingUsers,
requestUserInformation,
currentClosedChats,
} = this.props;
return (
@ -57,10 +56,10 @@ class UserContent extends PureComponent {
? (<UserMessages
{...{
isPublicChat,
activeChats,
compact,
intl,
roving,
currentClosedChats,
}}
/>
) : null

View File

@ -2,15 +2,19 @@ import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { Session } from 'meteor/session';
import Auth from '/imports/ui/services/auth';
import Storage from '/imports/ui/services/storage/session';
import UserContent from './component';
import GuestUsers from '/imports/api/guest-users/';
import Users from '/imports/api/users';
const CLOSED_CHAT_LIST_KEY = 'closedChatList';
const UserContentContainer = props => <UserContent {...props} />;
export default withTracker(() => ({
pollIsOpen: Session.equals('isPollOpen', true),
forcePollOpen: Session.equals('forcePollOpen', true),
currentClosedChats: Storage.getItem(CLOSED_CHAT_LIST_KEY) || [],
currentUser: Users.findOne({ userId: Auth.userID }, {
fields: {
userId: 1,

View File

@ -0,0 +1,19 @@
import React, { useEffect, useContext, useState } from 'react';
import UserMessages from './component';
import { ChatContext } from '/imports/ui/components/components-data/chat-context/context';
import { GroupChatContext } from '/imports/ui/components/components-data/group-chat-context/context';
import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
import Service from '/imports/ui/components/user-list/service';
const UserMessagesContainer = (props) => {
const usingChatContext = useContext(ChatContext);
const usingUsersContext = useContext(UsersContext);
const usingGroupChatContext = useContext(GroupChatContext);
const { chats: groupChatsMessages } = usingChatContext;
const { users } = usingUsersContext;
const { groupChat: groupChats } = usingGroupChatContext;
const activeChats = Service.getActiveChats({ groupChatsMessages, groupChats, users });
return <UserMessages {...{ ...props, activeChats }} />;
};
export default UserMessagesContainer;

View File

@ -34,8 +34,9 @@ class UnreadMessagesTracker {
return this._unreadChats[chatID];
}
getUnreadMessages(chatID) {
return [];
const filter = {
timestamp: {
$gt: this.get(chatID),
@ -55,7 +56,6 @@ class UnreadMessagesTracker {
}
}
const messages = GroupChatMsg.find(filter).fetch();
return messages;
}
count(chatID) {