refactor: redo how poll result chat message is done +

Fix prolems with ":" char in answers.
Respect poll question formatting (lineBreaks/spaces) in other fields of UI
This commit is contained in:
germanocaumo 2021-05-20 14:53:52 +00:00
parent cb4697c932
commit 3a9e9c1358
11 changed files with 154 additions and 142 deletions

View File

@ -24,6 +24,7 @@ export default function addSystemMsg(meetingId, chatId, msg) {
timestamp: Number,
sender: Object,
message: String,
extra: Object,
correlationId: Match.Maybe(String),
});
const msgDocument = {

View File

@ -11,9 +11,6 @@ export default function sendPollChatMsg({ body }, meetingId) {
const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid;
const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const MAX_POLL_RESULT_BARS = 20;
const { answers, numRespondents } = poll;
const pollData = Polls.findOne({ meetingId });
@ -22,36 +19,13 @@ export default function sendPollChatMsg({ body }, meetingId) {
return false;
}
const caseInsensitiveReducer = (acc, item) => {
const index = acc.findIndex(ans => ans.key.toLowerCase() === item.key.toLowerCase());
if(index !== -1) {
if(acc[index].numVotes >= item.numVotes) acc[index].numVotes += item.numVotes;
else {
const tempVotes = acc[index].numVotes;
acc[index] = item;
acc[index].numVotes += tempVotes;
}
} else {
acc.push(item);
}
return acc;
const pollResultData = poll;
pollResultData.pollType = pollData.pollType;
const extra = {
type: 'poll',
pollResultData,
};
let responded = 0;
let resultString = `bbb-published-poll-\n${pollData.question.split('<br/>').join('<br#>').split('\n').join('<br#>')}\n`;
answers.map((item) => {
responded += item.numVotes;
return item;
}).reduce(caseInsensitiveReducer, []).map((item) => {
item.key = item.key.split('<br/>').join('<br#>');
const numResponded = responded === numRespondents ? numRespondents : responded;
const pct = Math.round(item.numVotes / numResponded * 100);
const pctBars = "|".repeat(pct * MAX_POLL_RESULT_BARS / 100);
const pctFotmatted = `${Number.isNaN(pct) ? 0 : pct}%`;
resultString += `${item.key}: ${item.numVotes || 0} |${pctBars} ${pctFotmatted}\n`;
});
const payload = {
id: `${SYSTEM_CHAT_TYPE}-${CHAT_POLL_RESULTS_MESSAGE}`,
timestamp: Date.now(),
@ -60,7 +34,8 @@ export default function sendPollChatMsg({ body }, meetingId) {
id: PUBLIC_CHAT_SYSTEM_ID,
name: '',
},
message: resultString,
message: '',
extra,
};
removePoll(meetingId, pollData.id);

View File

@ -17,6 +17,8 @@ const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const ScrollCollection = new Mongo.Collection(null);
@ -28,8 +30,6 @@ 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 intlMessages = defineMessages({
publicChatClear: {
id: 'app.chat.clearPublicChatMessage',
@ -94,8 +94,8 @@ const reduceGroupMessages = (previous, current) => {
// between the two messages exceeds window and then group current
// message with the last one
const timeOfLastMessage = lastMessage.content[lastMessage.content.length - 1].time;
const isOrWasPoll = currentMessage.message.includes(POLL_MESSAGE_PREFIX)
|| lastMessage.message.includes(POLL_MESSAGE_PREFIX);
const isOrWasPoll = currentMessage.id.includes(CHAT_POLL_RESULTS_MESSAGE)
|| lastMessage.id.includes(CHAT_POLL_RESULTS_MESSAGE);
const groupingWindow = isOrWasPoll ? 0 : GROUPING_MESSAGES_WINDOW;
if (lastMessage.sender.id === currentMessage.sender.id

View File

@ -12,6 +12,7 @@ import { styles } from './styles';
const CHAT_CONFIG = Meteor.settings.public.chat;
const CHAT_CLEAR_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_clear;
const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
const propTypes = {
user: PropTypes.shape({
@ -78,7 +79,7 @@ class TimeWindowChatItem extends PureComponent {
intl,
} = this.props;
if (messages && messages[0].text.includes('bbb-published-poll-<br/>')) {
if (messages && messages[0].id.includes(CHAT_POLL_RESULTS_MESSAGE)) {
return this.renderPollItem();
}
@ -194,8 +195,9 @@ class TimeWindowChatItem extends PureComponent {
color,
intl,
isDefaultPoll,
extractPollQuestion,
getPollResultString,
messages,
extra,
scrollArea,
chatAreaId,
lastReadMessageTime,
@ -230,14 +232,15 @@ class TimeWindowChatItem extends PureComponent {
className={cx(styles.message, styles.pollWrapper)}
key={messages[0].id}
text={messages[0].text}
pollResultData={extra.pollResultData}
time={messages[0].time}
chatAreaId={chatAreaId}
lastReadMessageTime={lastReadMessageTime}
handleReadMessage={handleReadMessage}
scrollArea={scrollArea}
color={color}
isDefaultPoll={isDefaultPoll(messages[0].text.replace('bbb-published-poll-<br/>', ''))}
extractPollQuestion={extractPollQuestion}
isDefaultPoll={isDefaultPoll(extra.pollResultData.pollType)}
getPollResultString={getPollResultString}
/>
</div>
</div>

View File

@ -3,33 +3,13 @@ import TimeWindowChatItem from './component';
import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import ChatService from '../../service';
import PollService from '/imports/ui/components/poll/service';
import Auth from '/imports/ui/services/auth';
const CHAT_CONFIG = Meteor.settings.public.chat;
const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const extractPollQuestion = (pollText) => {
if (!pollText) return {};
const pollQuestion = pollText.split('<br/>')[0];
pollText = pollText.replace(`${pollQuestion}<br/>`,'');
return { pollQuestion, pollText };
};
const isDefaultPoll = (pollText) => {
const { pollQuestion, pollText: newPollText} = extractPollQuestion(pollText);
const pollValue = newPollText.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 function TimeWindowChatItemContainer(props) {
ChatLogger.debug('TimeWindowChatItemContainer::render', { ...props });
const { message, messageId } = props;
@ -40,6 +20,7 @@ export default function TimeWindowChatItemContainer(props) {
key,
timestamp,
content,
extra,
} = message;
const messages = content;
const user = users[Auth.meetingID][sender];
@ -55,8 +36,9 @@ export default function TimeWindowChatItemContainer(props) {
name: user?.name,
read: message.read,
messages,
isDefaultPoll,
extractPollQuestion,
extra,
isDefaultPoll: PollService.isDefaultPoll,
getPollResultString: PollService.getPollResultString,
user,
timestamp,
systemMessage: messageId.startsWith(SYSTEM_CHAT_TYPE) || !sender,

View File

@ -4,6 +4,7 @@ import _ from 'lodash';
import fastdom from 'fastdom';
import { defineMessages, injectIntl } from 'react-intl';
import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
import PollListItem from './poll-list-item/component';
const propTypes = {
text: PropTypes.string.isRequired,
@ -53,8 +54,6 @@ class MessageChatItem extends PureComponent {
this.ticking = false;
this.handleMessageInViewport = _.debounce(this.handleMessageInViewport.bind(this), 50);
this.renderPollListItem = this.renderPollListItem.bind(this);
}
componentDidMount() {
@ -162,91 +161,32 @@ class MessageChatItem extends PureComponent {
});
}
renderPollListItem() {
const {
intl,
text,
className,
color,
isDefaultPoll,
extractPollQuestion,
} = this.props;
const formatBoldBlack = s => s.bold().fontcolor('black');
// Sanitize. See: https://gist.github.com/sagewall/47164de600df05fb0f6f44d48a09c0bd
const sanitize = (value) => {
const div = document.createElement('div');
div.appendChild(document.createTextNode(value));
return div.innerHTML;
};
let _text = text.replace('bbb-published-poll-<br/>', '');
const { pollQuestion, pollText: newPollText } = extractPollQuestion(_text);
_text = newPollText;
if (!isDefaultPoll) {
const entries = _text.split('<br/>');
const options = [];
_text = _text.split('<br#>').join('<br/>');
entries.map((e) => {
e = e.split('<br#>').join('<br/>');
const sanitizedEntry = sanitize(e);
_text = _text.replace(e, sanitizedEntry);
e = sanitizedEntry;
options.push([e.slice(0, e.indexOf(':'))]);
return e;
});
options.map((o, idx) => {
if (o[0] !== '') {
_text = formatBoldBlack(_text.replace(o, idx + 1));
}
return _text;
});
_text += formatBoldBlack(`<br/><br/>${intl.formatMessage(intlMessages.legendTitle)}`);
options.map((o, idx) => {
if (o[0] !== '') {
_text += `<br/>${idx + 1}: ${o}`;
}
return _text;
});
}
if (isDefaultPoll) {
_text = formatBoldBlack(_text);
}
if (pollQuestion.trim() !== '') {
const sanitizedPollQuestion = sanitize(pollQuestion.split('<br#>').join(' '));
_text = `${formatBoldBlack(intl.formatMessage(intlMessages.pollQuestionTitle))}<br/>${sanitizedPollQuestion}<br/><br/>${_text}`;
}
return (
<p
className={className}
style={{ borderLeft: `3px ${color} solid` }}
ref={(ref) => { this.text = ref; }}
dangerouslySetInnerHTML={{ __html: _text }}
data-test="chatPollMessageText"
/>
);
}
render() {
const {
intl,
text,
type,
className,
isSystemMessage,
chatUserMessageItem,
systemMessageType,
color,
isDefaultPoll,
getPollResultString,
pollResultData,
} = this.props;
ChatLogger.debug('MessageChatItem::render', this.props);
if (type === 'poll') return this.renderPollListItem();
if (type === 'poll') return (
<PollListItem
intl={intl}
text={text}
pollResultData={pollResultData}
className={className}
color={color}
isDefaultPoll={isDefaultPoll}
getPollResultString={getPollResultString}
/>
)
return (
<p

View File

@ -0,0 +1,78 @@
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import cx from 'classnames';
import { styles } from './styles';
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
text: PropTypes.string.isRequired,
pollResultData: PropTypes.object.isRequired,
isDefaultPoll: PropTypes.bool.isRequired,
getPollResultString: PropTypes.func.isRequired,
};
const intlMessages = defineMessages({
legendTitle: {
id: 'app.polling.pollingTitle',
description: 'heading for chat poll legend',
},
pollQuestionTitle: {
id: 'app.polling.pollQuestionTitle',
description: 'title displayed before poll question',
},
});
function PollListItem(props) {
const {
intl,
pollResultData,
className,
color,
isDefaultPoll,
getPollResultString,
} = props;
const formatBoldBlack = s => s.bold().fontcolor('black');
// Sanitize. See: https://gist.github.com/sagewall/47164de600df05fb0f6f44d48a09c0bd
const sanitize = (value) => {
const div = document.createElement('div');
div.appendChild(document.createTextNode(value));
return div.innerHTML;
};
const { answers, numRespondents } = pollResultData;
let { resultString, optionsString } = getPollResultString(isDefaultPoll, answers, numRespondents)
resultString = sanitize(resultString);
optionsString = sanitize(optionsString);
let pollText = formatBoldBlack(resultString);
if (!isDefaultPoll) {
pollText += formatBoldBlack(`<br/><br/>${intl.formatMessage(intlMessages.legendTitle)}<br/>`);
pollText += optionsString;
}
const pollQuestion = pollResultData.title;
if (pollQuestion.trim() !== '') {
const sanitizedPollQuestion = sanitize(pollQuestion.split('<br#>').join(' '));
pollText = `${formatBoldBlack(intl.formatMessage(intlMessages.pollQuestionTitle))}<br/>${sanitizedPollQuestion}<br/><br/>${pollText}`;
}
return (
<p
className={cx(className, styles.pollText)}
style={{ borderLeft: `3px ${color} solid`}}
ref={(ref) => { this.text = ref; }}
dangerouslySetInnerHTML={{ __html: pollText }}
data-test="chatPollMessageText"
/>
);
}
PollListItem.propTypes = propTypes;
export default injectIntl(PollListItem);

View File

@ -167,7 +167,8 @@
.title {
font-weight: bold;
word-break: break-word;
word-break: break-all;
white-space: pre-wrap;
}
@keyframes ellipsis {

View File

@ -1,8 +1,10 @@
import Users from '/imports/api/users';
import Auth from '/imports/ui/services/auth';
import Polls from '/imports/api/polls';
import caseInsensitiveReducer from '/imports/utils/caseInsensitiveReducer';
const POLL_AVATAR_COLOR = '#3B48A9';
const MAX_POLL_RESULT_BARS = 20;
// 'YN' = Yes,No
// 'YNA' = Yes,No,Abstention
@ -56,6 +58,30 @@ const pollAnswerIds = {
},
};
const getPollResultString = (isDefaultPoll, answers, numRespondents) => {
let responded = 0;
let resultString = '';
let optionsString = '';
answers.map((item) => {
responded += item.numVotes;
return item;
}).reduce(caseInsensitiveReducer, []).map((item) => {
const numResponded = responded === numRespondents ? numRespondents : responded;
const pct = Math.round(item.numVotes / numResponded * 100);
const pctBars = "|".repeat(pct * MAX_POLL_RESULT_BARS / 100);
const pctFotmatted = `${Number.isNaN(pct) ? 0 : pct}%`;
if (isDefaultPoll) {
resultString += `${item.key}: ${item.numVotes || 0} |${pctBars} ${pctFotmatted}\n`;
} else {
resultString += `${item.id+1}: ${item.numVotes || 0} |${pctBars} ${pctFotmatted}\n`;
optionsString += `${item.id+1}: ${item.key}\n`;
}
});
return { resultString, optionsString };
}
export default {
amIPresenter: () => Users.findOne(
{ userId: Auth.userID },
@ -65,4 +91,6 @@ export default {
currentPoll: () => Polls.findOne({ meetingId: Auth.meetingID }),
pollAnswerIds,
POLL_AVATAR_COLOR,
isDefaultPoll: (pollType) => { return pollType !== 'custom' && pollType !== 'R-'},
getPollResultString: getPollResultString,
};

View File

@ -142,6 +142,7 @@
.qText {
color: var(--color-text);
word-break: break-word;
white-space: pre-wrap;
font-size: var(--font-size-large);
max-width: var(--poll-width);
padding-right: var(--sm-padding-x);