Browse Source

Refactor chat component to fix #1529

pull/1632/head
Gabe Kangas 4 years ago
parent
commit
f2e47c99a2
No known key found for this signature in database
GPG Key ID: 9A56337728BC81EA
  1. 321
      webroot/js/components/chat/chat.js
  2. 1
      webroot/js/utils/websocket.js

321
webroot/js/components/chat/chat.js

@ -15,6 +15,8 @@ import {
MESSAGE_JUMPTOBOTTOM_BUFFER, MESSAGE_JUMPTOBOTTOM_BUFFER,
} from '../../utils/constants.js'; } from '../../utils/constants.js';
const MAX_RENDER_BACKLOG = 300;
// Add message types that should be displayed in chat to this array. // Add message types that should be displayed in chat to this array.
const renderableChatStyleMessages = [ const renderableChatStyleMessages = [
SOCKET_MESSAGE_TYPES.NAME_CHANGE, SOCKET_MESSAGE_TYPES.NAME_CHANGE,
@ -30,7 +32,9 @@ export default class Chat extends Component {
this.state = { this.state = {
chatUserNames: [], chatUserNames: [],
messages: [], // Ordered array of messages sorted by timestamp.
sortedMessages: [],
newMessagesReceived: false, newMessagesReceived: false,
webSocketConnected: true, webSocketConnected: true,
isModerator: false, isModerator: false,
@ -42,8 +46,14 @@ export default class Chat extends Component {
this.receivedFirstMessages = false; this.receivedFirstMessages = false;
this.receivedMessageUpdate = false; this.receivedMessageUpdate = false;
this.hasFetchedHistory = false; this.hasFetchedHistory = false;
// Force render is a state to force the messages to re-render when visibility
// changes for messages. This overrides componentShouldUpdate logic.
this.forceRender = false; this.forceRender = false;
// Unordered dictionary of messages keyed by ID.
this.messages = {};
this.windowBlurred = false; this.windowBlurred = false;
this.numMessagesSinceBlur = 0; this.numMessagesSinceBlur = 0;
@ -81,8 +91,12 @@ export default class Chat extends Component {
const { username: nextUserName, chatInputEnabled: nextChatEnabled } = const { username: nextUserName, chatInputEnabled: nextChatEnabled } =
nextProps; nextProps;
const { webSocketConnected, messages, chatUserNames, newMessagesReceived } = const {
this.state; webSocketConnected,
chatUserNames,
newMessagesReceived,
sortedMessages,
} = this.state;
if (this.forceRender) { if (this.forceRender) {
return true; return true;
@ -90,34 +104,34 @@ export default class Chat extends Component {
const { const {
webSocketConnected: nextSocket, webSocketConnected: nextSocket,
messages: nextMessages,
chatUserNames: nextUserNames, chatUserNames: nextUserNames,
newMessagesReceived: nextMessagesReceived, newMessagesReceived: nextMessagesReceived,
} = nextState; } = nextState;
// If there are an updated number of sorted message then a render pass
// needs to take place to render these new messages.
if (
Object.keys(sortedMessages).length !==
Object.keys(nextState.sortedMessages).length
) {
return true;
}
if (newMessagesReceived) {
return true;
}
return ( return (
username !== nextUserName || username !== nextUserName ||
chatInputEnabled !== nextChatEnabled || chatInputEnabled !== nextChatEnabled ||
webSocketConnected !== nextSocket || webSocketConnected !== nextSocket ||
messages.length !== nextMessages.length ||
chatUserNames.length !== nextUserNames.length || chatUserNames.length !== nextUserNames.length ||
newMessagesReceived !== nextMessagesReceived newMessagesReceived !== nextMessagesReceived
); );
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { username: prevName } = prevProps; const { accessToken } = this.props;
const { username, accessToken } = this.props;
const { messages: prevMessages } = prevState;
const { messages } = this.state;
// scroll to bottom of messages list when new ones come in
if (messages.length !== prevMessages.length) {
this.setState({
newMessagesReceived: true,
});
}
// Fetch chat history // Fetch chat history
if (!this.hasFetchedHistory && accessToken) { if (!this.hasFetchedHistory && accessToken) {
@ -154,34 +168,31 @@ export default class Chat extends Component {
} }
// fetch chat history // fetch chat history
getChatHistory(accessToken) { async getChatHistory(accessToken) {
const { username } = this.props; const { username } = this.props;
fetch(URL_CHAT_HISTORY + `?accessToken=${accessToken}`) try {
.then((response) => { const response = await fetch(
if (!response.ok) { URL_CHAT_HISTORY + `?accessToken=${accessToken}`
throw new Error(`Network response was not ok ${response.ok}`); );
} const data = await response.json();
return response.json();
}) // Backlog of usernames from history
.then((data) => { const allChatUserNames = extraUserNamesFromMessageHistory(data);
// extra user names const chatUserNames = allChatUserNames.filter((name) => name != username);
const allChatUserNames = extraUserNamesFromMessageHistory(data);
const chatUserNames = allChatUserNames.filter(
(name) => name != username
);
this.setState((previousState, currentProps) => {
return {
...previousState,
messages: data.concat(previousState.messages),
chatUserNames,
};
});
this.scrollToBottom(); this.addNewRenderableMessages(data);
})
.catch((error) => { this.setState((previousState) => {
this.handleNetworkingError(`Fetch getChatHistory: ${error}`); return {
...previousState,
chatUserNames,
};
}); });
} catch (error) {
this.handleNetworkingError(`Fetch getChatHistory: ${error}`);
}
this.scrollToBottom();
} }
receivedWebsocketMessage(message) { receivedWebsocketMessage(message) {
@ -193,115 +204,152 @@ export default class Chat extends Component {
console.error('chat error', error); console.error('chat error', error);
} }
// Give a list of message IDs and the visibility state they should change to.
updateMessagesVisibility(idsToUpdate, visible) {
let messageList = { ...this.messages };
// Iterate through each ID and mark the associated ID in our messages
// dictionary with the new visibility.
for (const id of idsToUpdate) {
const message = messageList[id];
if (message) {
message.visible = visible;
messageList[id] = message;
}
}
const updatedMessagesList = {
...this.messages,
...messageList,
};
this.messages = updatedMessagesList;
this.forceRender = true;
}
handleChangeModeratorStatus(isModerator) {
if (isModerator !== this.state.isModerator) {
this.setState((previousState) => {
return { ...previousState, isModerator: isModerator };
});
}
}
handleWindowFocusNotificationCount(readonly, messageType) {
// if window is blurred and we get a new message, add 1 to title
if (
!readonly &&
messageType === SOCKET_MESSAGE_TYPES.CHAT &&
this.windowBlurred
) {
this.numMessagesSinceBlur += 1;
}
}
addNewRenderableMessages(messagesArray) {
// Convert the array of chat history messages into an object
// to be merged with the existing chat messages.
const newMessages = messagesArray.reduce(
(o, message) => ({ ...o, [message.id]: message }),
{}
);
// Keep our unsorted collection of messages keyed by ID.
const updatedMessagesList = {
...newMessages,
...this.messages,
};
this.messages = updatedMessagesList;
// Convert the unordered dictionary of messages to an ordered array.
// NOTE: This sorts the entire collection of messages on every new message
// because the order a message comes in cannot be trusted that it's the order
// it was sent, you need to sort by timestamp. I don't know if there
// is a performance problem waiting to occur here for larger chat feeds.
var sortedMessages = Object.values(updatedMessagesList)
// Filter out messages set to not be visible
.filter((message) => message.visible !== false)
.sort((a, b) => {
return Date.parse(a.timestamp) - Date.parse(b.timestamp);
});
// Cap this list to 300 items to improve browser performance.
if (sortedMessages.length >= MAX_RENDER_BACKLOG) {
sortedMessages = sortedMessages.slice(
sortedMessages.length - MAX_RENDER_BACKLOG
);
}
this.setState((previousState) => {
return {
...previousState,
newMessagesReceived: true,
sortedMessages,
};
});
}
// handle any incoming message // handle any incoming message
handleMessage(message) { handleMessage(message) {
const { const { type: messageType } = message;
id: messageId, const { readonly, username } = this.props;
type: messageType,
timestamp: messageTimestamp,
} = message;
const { messages: curMessages } = this.state;
const { username, readonly } = this.props;
const existingIndex = curMessages.findIndex(
(item) => item.id === messageId
);
// Allow non-user chat messages to be visible by default. // Allow non-user chat messages to be visible by default.
const messageVisible = const messageVisible =
message.visible || messageType !== SOCKET_MESSAGE_TYPES.CHAT; message.visible || messageType !== SOCKET_MESSAGE_TYPES.CHAT;
// check moderator status // Show moderator status
if (messageType === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) { if (messageType === SOCKET_MESSAGE_TYPES.CONNECTED_USER_INFO) {
const modStatusUpdate = checkIsModerator(message); const modStatusUpdate = checkIsModerator(message);
if (modStatusUpdate !== this.state.isModerator) { this.handleChangeModeratorStatus(modStatusUpdate);
this.setState((previousState, currentProps) => {
return { ...previousState, isModerator: modStatusUpdate };
});
}
} }
const updatedMessageList = [...curMessages];
// Change the visibility of messages by ID. // Change the visibility of messages by ID.
if (messageType === 'VISIBILITY-UPDATE') { if (messageType === SOCKET_MESSAGE_TYPES.VISIBILITY_UPDATE) {
const idsToUpdate = message.ids; const idsToUpdate = message.ids;
const visible = message.visible; const visible = message.visible;
updatedMessageList.forEach((item) => { this.updateMessagesVisibility(idsToUpdate, visible);
if (idsToUpdate.includes(item.id)) {
item.visible = visible;
}
});
this.forceRender = true;
} else if ( } else if (
renderableChatStyleMessages.includes(messageType) && renderableChatStyleMessages.includes(messageType) &&
existingIndex === -1 &&
messageVisible messageVisible
) { ) {
// insert message at timestamp // Add new message to the chat feed.
const convertedMessage = { this.addNewRenderableMessages([message]);
...message,
}; // Update the usernames list, filtering out our own name.
const insertAtIndex = curMessages.findIndex((item, index) => {
const time = item.timestamp || messageTimestamp;
const nextMessage =
index < curMessages.length - 1 && curMessages[index + 1];
const nextTime = nextMessage.timestamp || messageTimestamp;
const messageTimestampDate = new Date(messageTimestamp);
return (
messageTimestampDate > new Date(time) &&
messageTimestampDate <= new Date(nextTime)
);
});
updatedMessageList.splice(insertAtIndex + 1, 0, convertedMessage);
if (updatedMessageList.length > 300) {
updatedMessageList = updatedMessageList.slice(
Math.max(updatedMessageList.length - 300, 0)
);
}
this.setState((previousState, currentProps) => {
return { ...previousState, messages: updatedMessageList };
});
} else if (
renderableChatStyleMessages.includes(messageType) &&
existingIndex === -1
) {
// else if message doesn't exist, add it and extra username
const newState = {
messages: [...curMessages, message],
};
const updatedAllChatUserNames = this.updateAuthorList(message); const updatedAllChatUserNames = this.updateAuthorList(message);
if (updatedAllChatUserNames.length) { if (updatedAllChatUserNames.length) {
const updatedChatUserNames = updatedAllChatUserNames.filter( const updatedChatUserNames = updatedAllChatUserNames.filter(
(name) => name != username (name) => name != username
); );
newState.chatUserNames = [...updatedChatUserNames]; this.setState((previousState) => {
return {
...previousState,
chatUserNames: [...updatedChatUserNames],
};
});
} }
this.setState((previousState, currentProps) => {
return { ...previousState, newState };
});
} }
// if window is blurred and we get a new message, add 1 to title // Update the window title if needed.
if ( this.handleWindowFocusNotificationCount(readonly, messageType);
!readonly &&
messageType === SOCKET_MESSAGE_TYPES.CHAT &&
this.windowBlurred
) {
this.numMessagesSinceBlur += 1;
}
} }
websocketConnected() { websocketConnected() {
this.setState({ this.setState((previousState) => {
webSocketConnected: true, return {
...previousState,
webSocketConnected: true,
};
}); });
} }
websocketDisconnected() { websocketDisconnected() {
this.setState({ this.setState((previousState) => {
webSocketConnected: false, return {
...previousState,
webSocketConnected: false,
};
}); });
} }
@ -309,7 +357,6 @@ export default class Chat extends Component {
if (!content) { if (!content) {
return; return;
} }
const { username } = this.props;
const message = { const message = {
body: content, body: content,
type: SOCKET_MESSAGE_TYPES.CHAT, type: SOCKET_MESSAGE_TYPES.CHAT,
@ -333,6 +380,7 @@ export default class Chat extends Component {
nameList.splice(oldNameIndex, 1, user.displayName); nameList.splice(oldNameIndex, 1, user.displayName);
return nameList; return nameList;
} }
return []; return [];
} }
@ -379,8 +427,12 @@ export default class Chat extends Component {
} else if (this.checkShouldScroll()) { } else if (this.checkShouldScroll()) {
this.scrollToBottom(); this.scrollToBottom();
} }
this.setState({
newMessagesReceived: false, this.setState((previousState) => {
return {
...previousState,
newMessagesReceived: false,
};
}); });
} }
} }
@ -404,20 +456,19 @@ export default class Chat extends Component {
render(props, state) { render(props, state) {
const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } = const { username, readonly, chatInputEnabled, inputMaxBytes, accessToken } =
props; props;
const { messages, chatUserNames, webSocketConnected, isModerator } = state; const { sortedMessages, chatUserNames, webSocketConnected, isModerator } =
state;
const messageList = messages
.filter((message) => message.visible !== false) const messageList = sortedMessages.map(
.map( (message) =>
(message) => html`<${Message}
html`<${Message} message=${message}
message=${message} username=${username}
username=${username} key=${message.id}
key=${message.id} isModerator=${isModerator}
isModerator=${isModerator} accessToken=${accessToken}
accessToken=${accessToken} />`
/>` );
);
if (readonly) { if (readonly) {
return html` return html`

1
webroot/js/utils/websocket.js

@ -16,6 +16,7 @@ export const SOCKET_MESSAGE_TYPES = {
ERROR_USER_DISABLED: 'ERROR_USER_DISABLED', ERROR_USER_DISABLED: 'ERROR_USER_DISABLED',
ERROR_NEEDS_REGISTRATION: 'ERROR_NEEDS_REGISTRATION', ERROR_NEEDS_REGISTRATION: 'ERROR_NEEDS_REGISTRATION',
ERROR_MAX_CONNECTIONS_EXCEEDED: 'ERROR_MAX_CONNECTIONS_EXCEEDED', ERROR_MAX_CONNECTIONS_EXCEEDED: 'ERROR_MAX_CONNECTIONS_EXCEEDED',
VISIBILITY_UPDATE: 'VISIBILITY-UPDATE',
}; };
export const CALLBACKS = { export const CALLBACKS = {

Loading…
Cancel
Save