2017-06-08_509bba0/509bba0_unpacked_with_node_modules/discord_app/components/Messages.js
2022-07-26 10:06:20 -07:00

890 lines
30 KiB
JavaScript
Executable file

import React from 'react';
import ReactDOM from 'react-dom';
import lodash from 'lodash';
import classNames from 'classnames';
import Avatar from './common/Avatar';
import Progress from './common/Progress';
import Scroller from './common/Scroller';
import Spinner from './common/Spinner';
import WelcomeMessage from './WelcomeMessage';
import EmptyMessage from './EmptyMessage';
import UserStore from '../stores/UserStore';
import MessageStore from '../stores/MessageStore';
import UploadStore from '../stores/UploadStore';
import DimensionStore from '../stores/DimensionStore';
import GuildVerificationStore from '../stores/GuildVerificationStore';
import UserSettingsStore from '../stores/UserSettingsStore';
import ReadStateStore from '../stores/ReadStateStore';
import PermissionMixin from '../mixins/PermissionMixin';
import ChannelRecord from '../records/ChannelRecord';
import ReadStateActionCreators from '../actions/ReadStateActionCreators';
import MessageActionCreators from '../actions/MessageActionCreators';
import DimensionActionCreators from '../actions/DimensionActionCreators';
import MessageGroup from './MessageGroup';
import {hasAnimatedAvatar} from '../utils/AvatarUtils';
import ReportUtils from '../utils/ReportUtils';
import i18n from '../i18n';
import Flux from '../lib/flux';
import moment from 'moment';
import '../stores/TTSStore';
import {
MAX_MESSAGES_PER_CHANNEL,
ChannelStreamTypes,
Permissions,
MessageTypes,
ChannelTypes,
ComponentActions,
} from '../Constants';
import ComponentDispatchMixin from '../mixins/ComponentDispatchMixin';
import './Messages.styl';
const SCROLLER_REF = 'SCROLLER_REF';
const NEW_MESSAGES_REF = 'NEW_MESSAGES_REF';
const JUMP_TARGET_REF = 'JUMP_TARGET_REF';
const OFFSET_TO_PREVENT_LOADING = 100;
const NEW_MESSAGE_BAR_HEIGHT = 35;
const BlockedMessageGroups = React.createClass({
propTypes: {
canManageMessages: React.PropTypes.bool,
channel: React.PropTypes.object.isRequired,
messageGroups: React.PropTypes.array.isRequired,
onUpdate: React.PropTypes.func,
onEdit: React.PropTypes.func,
onReveal: React.PropTypes.func.isRequired,
inlineAttachmentMedia: React.PropTypes.bool.isRequired,
inlineEmbedMedia: React.PropTypes.bool.isRequired,
renderEmbeds: React.PropTypes.bool.isRequired,
compact: React.PropTypes.bool,
},
getDefaultProps() {
return {
canManageMessages: false,
compact: false,
};
},
handleClick() {
this.props.onReveal(this.props.revealed ? null : this.props.messageGroups[0].content /* messages */[0].id);
},
render() {
const {revealed} = this.props;
const messageGroups = [];
let count = 0;
this.props.messageGroups.forEach(({content: messages}) => {
count += messages.length;
messageGroups.push(<MessageGroup key={messages[0].id} messages={messages} hasDivider={false} {...this.props} />);
});
return (
<div className={classNames('message-group-blocked', {revealed})}>
<div className="message-group-blocked-btn" onClick={this.handleClick}>
{i18n.Messages.BLOCKED_MESSAGES.format({count})}
</div>
{revealed ? messageGroups : null}
</div>
);
},
});
const PendingUpload = React.createClass({
propTypes: {
file: React.PropTypes.object.isRequired,
user: React.PropTypes.object.isRequired,
},
getInitialState() {
return {
animate: false,
};
},
render() {
const {user, file} = this.props;
const {animate} = this.state;
const isAnimated = hasAnimatedAvatar(user);
return (
<div
onMouseEnter={isAnimated && this.onMouseEnter}
onMouseLeave={isAnimated && this.onMouseLeave}
className="message-group">
<Avatar user={user} size="large" animate={animate} />
<div className="comment">
<h2><strong>{user.toString()}</strong></h2>
<div className="attachment">
<div className={classNames('icon icon-file', {[this.props.file.classification]: true})} />
<div className="upload">
<div className="filename">{file.toString()}</div>
<div className="speed">{file.getSpeed()}</div>
<Progress percent={file.getPercentComplete()} />
</div>
</div>
</div>
</div>
);
},
onMouseEnter() {
this.setState({animate: true});
},
onMouseLeave() {
this.setState({animate: false});
},
});
class Divider extends React.PureComponent {
render() {
return (
<div className={classNames('divider', this.props.red && 'divider-red')}>
<div />
<span>{this.props.children}</span>
<div />
</div>
);
}
}
const Messages = React.createClass({
mixins: [
Flux.StoreListenerMixin(
UserStore,
MessageStore,
UploadStore,
DimensionStore,
ReadStateStore,
UserSettingsStore,
GuildVerificationStore
),
PermissionMixin,
ComponentDispatchMixin,
],
propTypes: {
channel: React.PropTypes.instanceOf(ChannelRecord),
},
getSubscriptions() {
return {
[ComponentActions.SCROLLTO_PRESENT]: this.scrollToBottom,
[ComponentActions.SCROLL_PAGE_UP]: () => this.scrollPageUp(true),
[ComponentActions.SCROLL_PAGE_DOWN]: () => this.scrollPageDown(true),
};
},
getInitialState() {
return {
scrollingToJump: false,
};
},
getStateFromStores() {
const channel = this.props.channel;
const {
inlineAttachmentMedia,
inlineEmbedMedia,
renderEmbeds,
renderReactions,
developerMode,
messageDisplayCompact,
} = UserSettingsStore;
return {
messages: MessageStore.getMessages(channel.id),
uploads: UploadStore.getFiles(channel.id),
dimensions: DimensionStore.getChannelDimensions(channel.id),
unreadCount: ReadStateStore.getUnreadCount(channel.id),
oldestUnreadMessageId: ReadStateStore.getOldestUnreadMessageId(channel.id),
ackMessageId: ReadStateStore.ackMessageId(channel.id),
inlineAttachmentMedia,
inlineEmbedMedia,
renderEmbeds,
renderReactions,
messageDisplayCompact,
developerMode,
canChat: channel.isPrivate() || GuildVerificationStore.canChatInGuild(channel.getGuildId()),
canReport: ReportUtils.canReportInChannel(channel),
};
},
componentDidMount() {
this.restoreScroll();
// When an <iframe> goes to fullscreen it changes the size of this node which breaks the scroll position tracking.
// This is only a bug in Chrome (and Electron) so therefore we only implement it for WebKit events.
document.addEventListener('webkitfullscreenchange', this.handleWebKitFullscreenChange);
},
componentWillUnmount() {
document.removeEventListener('webkitfullscreenchange', this.handleWebKitFullscreenChange);
},
shouldComponentUpdate(nextProps, nextState) {
return (
this.state.messages !== nextState.messages ||
this.state.uploads !== nextState.uploads ||
this.state.unreadCount !== nextState.unreadCount ||
this.state.oldestUnreadMessageId !== nextState.oldestUnreadMessageId ||
this.props.channel != nextProps.channel ||
this.state.inlineAttachmentMedia != nextState.inlineAttachmentMedia ||
this.state.inlineEmbedMedia != nextState.inlineEmbedMedia ||
this.state.renderEmbeds != nextState.renderEmbeds ||
this.state.renderReactions != nextState.renderReactions ||
this.state.developerMode != nextState.developerMode ||
this.state.messageDisplayCompact != nextState.messageDisplayCompact ||
this.state.canChat != nextState.canChat ||
this.didPermissionsUpdate(nextState, nextProps.channel)
);
},
scrollToJumpTarget(prevMessages, messages) {
const jumpTargetRef = this.refs[JUMP_TARGET_REF];
if (!jumpTargetRef) {
// jumping failed likely due to the message being deleted, but we have a new message bar so just go there.
const newMessagesRef = this.refs[NEW_MESSAGES_REF];
if (newMessagesRef) {
this.scrollToNewMessages(true);
return;
}
console.warn('Got a jump target request but the ref was not found in the channel');
return;
}
const jumpTarget = ReactDOM.findDOMNode(jumpTargetRef.getMessage(this.jumpTargetIndex));
const scroller = this.getScroller();
const node = this.getScrollerDOMNode();
const middle = jumpTarget.offsetTop - node.offsetHeight * 0.5 + jumpTarget.offsetHeight * 0.5;
// if messages were reloaded, then we need to do fancy things to prep for a smooth scroll to jumpTarget
if (messages.last() !== prevMessages.last() && messages.first() !== prevMessages.first()) {
if (
prevMessages.first() &&
messages.get(messages.jumpTargetId).timestamp.isBefore(prevMessages.first().timestamp)
) {
// New messages are farther in the past, so we want to scroll up to them.
// That means our initial offset is at the bottom.
scroller.scrollTo(node.scrollHeight - node.offsetHeight - OFFSET_TO_PREVENT_LOADING);
} else {
// New messages are farther in the future, so we want to scroll down to them.
// That means our initial offset is at the top.
scroller.scrollTo(OFFSET_TO_PREVENT_LOADING);
}
}
const myJumpTargetId = messages.jumpTargetId;
this.setState({scrollingToJump: true}, () => {
scroller.scrollTo(middle, true, () => {
if (this.state.messages.jumpTargetId === myJumpTargetId) {
this.setState({scrollingToJump: false});
}
});
});
},
componentDidUpdate(prevProps, prevState) {
const prevMessages = prevState.messages;
const messages = this.state.messages;
// We jumped and we have a specific target
if (messages.jumpTargetId !== null && prevMessages.jumpSequenceId !== messages.jumpSequenceId) {
this.scrollToJumpTarget(prevMessages, messages);
} else if (messages.jumpedToPresent && prevMessages.jumpSequenceId !== messages.jumpSequenceId) {
// We jumped and we are going to the present
this.getScroller().scrollTo(OFFSET_TO_PREVENT_LOADING);
this.scrollToBottom(true);
} else if (
this.state.inlineAttachmentMedia != prevState.inlineAttachmentMedia ||
this.state.inlineEmbedMedia != prevState.inlineEmbedMedia ||
this.state.renderEmbeds != prevState.renderEmbeds
) {
// If any message settings change then just scroll to bottom to avoid complicated logic.
this.scrollToBottom();
} else if (this.state.uploads.length > prevState.uploads.length) {
// Current user started an upload. The upload status renders on the bottom so scroll
// to show feedback about the upload to the user.
this.scrollToBottom();
} else if (this.state.messageDisplayCompact !== prevState.messageDisplayCompact && this.isAtBottom()) {
// Prevent scroll jank when switching appearance modes and being at the bottom of the chat
this.scrollToBottom();
} else if (this.state.messages.last() !== prevState.messages.last()) {
// Last message has changed.
// This was an explicit load from the bottom most likely
if (prevState.messages.loadingMore) {
// it was an initial load. Try to scroll to new messages.
if (prevState.messages.length === 0) {
this.restoreScroll();
} else {
// don't do anything, appending to bottom will cause scrollbar to do the right thing
// truncate messages at the top if we have too many
const node = this.getScrollerDOMNode();
DimensionActionCreators.updateChannelDimensions(
this.props.channel.id,
node.scrollTop,
node.scrollHeight,
node.offsetHeight
);
MessageActionCreators.truncateMessages(this.props.channel.id, false, true);
}
} else if (!this.state.messages.hasMoreAfter) {
// A new message has arrived scroll to bottom if it is by the current user or the user was
// already at the bottom AND we are not infinite scrolling.
const latestMessage = this.state.messages.last();
const currentUser = UserStore.getCurrentUser();
if ((latestMessage && latestMessage.author.id === currentUser.id) || this.isAtBottom()) {
this.scrollToBottom();
}
}
} else if (this.state.messages.first() !== prevState.messages.first()) {
// The first message changed and was previously loading more.
if (prevState.messages.loadingMore) {
this.handleLoadMore();
// truncate messages at the bottom if we have too many
const node = this.getScrollerDOMNode();
DimensionActionCreators.updateChannelDimensions(
this.props.channel.id,
node.scrollTop,
node.scrollHeight,
node.offsetHeight
);
MessageActionCreators.truncateMessages(this.props.channel.id, true, false);
} else if (prevState.dimensions) {
// if we had dimensions and weren't loading restore our position
this.scrollTo(
prevState.dimensions.scrollTop - (prevState.dimensions.scrollHeight - this.getScrollerDOMNode().scrollHeight)
);
}
}
},
render() {
const {messages, canChat, canReport} = this.state;
const currentUser = UserStore.getCurrentUser();
const canManageMessages = this.can(Permissions.MANAGE_MESSAGES, this.props.channel);
const canAddNewReactions = canChat && this.can(Permissions.ADD_REACTIONS, this.props.channel);
const channelStream = this.createChannelStream(messages);
const channelStreamMarkup = channelStream.map(({type, content}, i) => {
if (type == ChannelStreamTypes.DIVIDER_TIME_STAMP) {
return <Divider key={i}>{content}</Divider>;
}
if (type == ChannelStreamTypes.DIVIDER_NEW_MESSAGES) {
return <Divider key={i} ref={NEW_MESSAGES_REF} red={true}>{content}</Divider>;
}
const props = {
compact: this.state.messageDisplayCompact,
channel: this.props.channel,
canManageMessages,
canAddNewReactions,
canChat,
canReport,
inlineAttachmentMedia: this.state.inlineAttachmentMedia,
inlineEmbedMedia: this.state.inlineEmbedMedia,
renderEmbeds: this.state.renderEmbeds,
renderReactions: this.state.renderReactions,
developerMode: this.state.developerMode,
onEdit: this.handleMessageEdit,
onUpdate: this.handleMessageUpdate,
};
if (type == ChannelStreamTypes.MESSAGE_GROUP_BLOCKED) {
return (
<BlockedMessageGroups
key={i}
revealed={content[0] /* messageGroup */.content /* messages */[0].id === messages.revealedMessageId}
messageGroups={content}
onReveal={this.handleReveal}
{...props}
/>
);
}
const nextItemIsDivider =
i !== channelStream.length - 1 &&
(channelStream[i + 1].type == ChannelStreamTypes.DIVIDER_TIME_STAMP ||
channelStream[i + 1].type == ChannelStreamTypes.DIVIDER_NEW_MESSAGES ||
channelStream[i + 1].type == ChannelStreamTypes.MESSAGE_GROUP_BLOCKED);
if (type == ChannelStreamTypes.JUMP_TARGET) {
props.ref = JUMP_TARGET_REF;
props.jumpTargetIndex = this.jumpTargetIndex;
props.jumpSequenceId = messages.jumpSequenceId;
props.canFlash = messages.jumpFlash;
}
return <MessageGroup key={content[0].id} messages={content} hasDivider={nextItemIsDivider} {...props} />;
});
// Pending Uploads
this.state.uploads.forEach(file => {
channelStreamMarkup.push(<PendingUpload key={`upload-${file.id}`} file={file} user={currentUser} />);
});
// Scrolled to edge and loading more messages
if (messages.loadingMore) {
if (messages.hasMoreBefore) {
channelStreamMarkup.unshift(
<div key="loading-more" ref="loading-more" className="loading-more"><Spinner /></div>
);
}
if (messages.hasMoreAfter) {
channelStreamMarkup.push(
<div key="loading-more-after" ref="loading-more" className="loading-more"><Spinner /></div>
);
}
} else if (messages.hasMoreBefore) {
channelStreamMarkup.unshift(
<div key="has-more" className="has-more">
<button type="button" onClick={this.loadMore.bind(this, false)}>
{i18n.Messages.LOAD_MORE_MESSAGES}
</button>
</div>
);
}
if (!messages.hasMoreBefore) {
const channel = this.props.channel;
if (this.props.channel.type === ChannelTypes.DM) {
channelStreamMarkup.unshift(
<EmptyMessage key="empty-message">
<h1>{i18n.Messages.BEGINNING_DM.format({username: channel.toString()})}</h1>
</EmptyMessage>
);
} else if (this.props.channel.type === ChannelTypes.GROUP_DM) {
if (this.props.channel.isManaged()) {
channelStreamMarkup.unshift(
<EmptyMessage key="empty-message">
<h1>{i18n.Messages.BEGINNING_GROUP_DM_MANAGED}</h1>
</EmptyMessage>
);
} else {
channelStreamMarkup.unshift(
<EmptyMessage key="empty-message">
<h1>{i18n.Messages.BEGINNING_GROUP_DM.format({name: channel.toString()})}</h1>
</EmptyMessage>
);
}
} else if (this.can(Permissions.READ_MESSAGE_HISTORY, channel)) {
if (channel.isDefaultChannel()) {
channelStreamMarkup.unshift(<WelcomeMessage key="welcome-message" channel={channel} />);
} else {
channelStreamMarkup.unshift(
<EmptyMessage key="empty-message">
<h1>{i18n.Messages.BEGINNING_CHANNEL.format({channelName: channel.toString()})}</h1>
</EmptyMessage>
);
}
} else {
channelStreamMarkup.unshift(
<div key="empty-message" className="empty-message">
<div className="channel-icon" />
<h1>{i18n.Messages.BEGINNING_CHANNEL_NO_HISTORY.format({channelName: channel.toString()})}</h1>
</div>
);
}
}
if (!messages.loadingMore && messages.hasMoreAfter) {
channelStreamMarkup.push(
<div key="has-more-after" className="has-more">
<button type="button" onClick={this.loadMore.bind(this, true)}>
{i18n.Messages.LOAD_MORE_MESSAGES}
</button>
</div>
);
}
let newMessagesBar;
if (this.state.unreadCount > 0) {
const timestamp = ReadStateStore.getOldestUnreadTimestamp(this.props.channel.id);
let newText;
const isSameDay = moment().isSame(timestamp, 'day');
if (ReadStateStore.isEstimated(this.props.channel.id)) {
newText = isSameDay ? i18n.Messages.NEW_MESSAGES_ESTIMATED : i18n.Messages.NEW_MESSAGES_ESTIMATED_WITH_DATE;
} else {
newText = isSameDay ? i18n.Messages.NEW_MESSAGES : i18n.Messages.NEW_MESSAGES_WITH_DATE;
}
newMessagesBar = (
<div className="new-messages-bar">
<button type="button" onClick={this.handleScrollToNewMessages}>
{newText.format({count: this.state.unreadCount, timestamp})}
</button>
<button type="button" onClick={this.handleAck}>
{i18n.Messages.MARK_AS_READ}
</button>
</div>
);
}
let jumpToPresentBar;
if (this.state.messages.hasMoreAfter) {
if (this.state.messages.loadingMore && this.state.messages.jumpedToPresent) {
jumpToPresentBar = (
<div className="jump-to-present-bar">
<button type="button">
{i18n.Messages.YOURE_VIEWING_OLDER_MESSAGES}
</button>
<Spinner type="pulsing-ellipsis" />
</div>
);
} else {
jumpToPresentBar = (
<div className="jump-to-present-bar">
<button type="button" onClick={this.jumpToPresent}>
{i18n.Messages.YOURE_VIEWING_OLDER_MESSAGES}
</button>
<button type="button" onClick={this.jumpToPresent}>
{i18n.Messages.JUMP_TO_PRESENT}
</button>
</div>
);
}
}
return (
<div className="messages-wrapper">
{newMessagesBar}
<Scroller ref={SCROLLER_REF} className="messages" onScroll={this.handleScroll} onResize={this.handleResize}>
{channelStreamMarkup}
</Scroller>
{jumpToPresentBar}
</div>
);
},
// Utils.
createChannelStream(messages) {
const messageGroups = [];
messages.forEach(message => {
const lastMessageGroup = messageGroups[messageGroups.length - 1];
if (
lastMessageGroup == null ||
message.id === this.state.oldestUnreadMessageId ||
message.type > MessageTypes.DEFAULT ||
lastMessageGroup[0].type !== message.type ||
lastMessageGroup[0].author.id !== message.author.id ||
lastMessageGroup[0].nick !== message.nick ||
(message.webhook_id && lastMessageGroup[0].author.username !== message.author.username) ||
lastMessageGroup[0].timestamp.day() !== message.timestamp.day() ||
lastMessageGroup[0].timestamp.hour() !== message.timestamp.hour()
) {
messageGroups.push([message]);
} else {
lastMessageGroup.push(message);
}
});
let lastTimestamp;
const channelStream = [];
messageGroups.forEach(messageGroup => {
if (messageGroup[0].id === this.state.oldestUnreadMessageId) {
channelStream.push({
type: ChannelStreamTypes.DIVIDER_NEW_MESSAGES,
content: i18n.Messages.NEW_MESSAGES_DIVIDER,
});
}
const timestamp = messageGroup[0].timestamp.format('LL');
if (timestamp !== lastTimestamp) {
if (lastTimestamp != null) {
channelStream.push({
type: ChannelStreamTypes.DIVIDER_TIME_STAMP,
content: timestamp,
});
}
lastTimestamp = timestamp;
}
if (messageGroup[0].blocked) {
let lastChannelStreamItem = channelStream[channelStream.length - 1];
if (lastChannelStreamItem == null || lastChannelStreamItem.type !== ChannelStreamTypes.MESSAGE_GROUP_BLOCKED) {
lastChannelStreamItem = {
type: ChannelStreamTypes.MESSAGE_GROUP_BLOCKED,
content: [],
};
channelStream.push(lastChannelStreamItem);
}
lastChannelStreamItem.content.push({
type: ChannelStreamTypes.MESSAGE_GROUP,
content: messageGroup,
});
} else {
let type = ChannelStreamTypes.MESSAGE_GROUP;
if (this.state.messages.jumpTargetId) {
const index = lodash.findIndex(messageGroup, msg => msg.id == messages.jumpTargetId);
if (index >= 0) {
type = ChannelStreamTypes.JUMP_TARGET;
this.jumpTargetIndex = index;
}
}
channelStream.push({
type,
content: messageGroup,
});
}
});
return channelStream;
},
loadMore(bottom = false) {
if (this.state.scrollingToJump) {
return;
}
if (bottom) {
MessageActionCreators.fetchMessages(
this.props.channel.id,
null,
this.state.messages.last().id,
MAX_MESSAGES_PER_CHANNEL
);
} else {
const firstMessageId = this.state.messages.first() != null ? this.state.messages.first().id : null;
MessageActionCreators.fetchMessages(this.props.channel.id, firstMessageId, null, MAX_MESSAGES_PER_CHANNEL);
}
},
jumpToPresent() {
if (!this.state.loadingMore) {
MessageActionCreators.jumpToPresent(this.props.channel.id, MAX_MESSAGES_PER_CHANNEL);
}
},
// Scroller
getScroller() {
return this.refs[SCROLLER_REF];
},
getScrollerDOMNode() {
return this.getScroller().getScrollerNode();
},
scrollTo(offset, animated = false) {
const node = this.getScrollerDOMNode();
// If this view is already not scrollable then there is no point in setting
// the scroll top. However, this code depends on handleScroll being fired so
// just manually fire it.
if (node.scrollHeight === node.offsetHeight) {
this.handleScroll();
} else {
this.getScroller().scrollTo(offset, animated);
}
},
scrollBy(offset) {
this.scrollTo(this.getScrollerDOMNode().scrollTop + offset);
},
scrollToNewMessages(animated = false, loadMore = false) {
this._loadMoreScrollToNewMessages = false;
const newMessages = ReactDOM.findDOMNode(this.refs[NEW_MESSAGES_REF]);
if (newMessages != null) {
this.scrollTo(Math.max(0, newMessages.offsetTop - NEW_MESSAGE_BAR_HEIGHT), animated);
if (loadMore && ReadStateStore.isEstimated(this.props.channel.id)) {
this._loadMoreScrollToNewMessages = true;
this.loadMore();
}
} else if (this.state.oldestUnreadMessageId != null) {
// We can not have a REF bar, but hav a valid unread message if it's in the channel's before cache
this.scrollTo(NEW_MESSAGE_BAR_HEIGHT, animated);
return true;
}
return newMessages != null;
},
scrollToBottom(animated = false) {
if (this.state.messages.hasMoreAfter) {
this.jumpToPresent();
} else {
this.scrollTo(this.getScrollerDOMNode().scrollHeight, animated);
}
},
scrollPageUp(animated = false) {
const scroller = this.refs[SCROLLER_REF];
if (scroller) {
scroller.scrollPageUp(animated);
}
},
scrollPageDown(animated = false) {
const scroller = this.refs[SCROLLER_REF];
if (scroller) {
scroller.scrollPageDown(animated);
}
},
restoreScroll() {
// Assume user wants to start at the new messages.
if (this.scrollToNewMessages()) {
// noop
} else if (this.state.dimensions != null) {
// If the user previously scrolled then restore their position.
this.scrollTo(this.state.dimensions.scrollTop);
} else {
// Otherwise just scroll to bottom.
this.scrollToBottom();
}
},
isAtBottom() {
return this.state.dimensions == null;
},
isWebKitFullscreen() {
return document.webkitFullscreenElement != null;
},
handleWebKitFullscreenChange() {
if (!this.isWebKitFullscreen()) {
const {dimensions} = this.state;
if (dimensions != null) {
this.scrollTo(dimensions.scrollTop);
} else {
this.scrollToBottom();
}
}
},
/**
* Loading more has completed and new messages have come in.
*/
handleLoadMore() {
if (!this.isAtBottom()) {
this.scrollTo(this.getScrollerDOMNode().scrollHeight - this.state.dimensions.scrollHeight);
if (this._loadMoreScrollToNewMessages) {
this.scrollToNewMessages(true);
}
}
},
/**
* A message has updated with new content then the scroll needs to be offset by the height change, but
* only if the message is above the current scroll.
*/
handleMessageUpdate(targetNode) {
const node = this.getScrollerDOMNode();
if (targetNode.offsetTop < node.scrollTop + node.offsetHeight) {
if (this.isAtBottom()) {
this.scrollToBottom();
} else {
this.scrollBy(node.scrollHeight - this.state.dimensions.scrollHeight);
}
}
},
/**
* A message starts editing then needs to scroll if the message is off screen
*/
handleMessageEdit(targetNode) {
const node = this.getScrollerDOMNode();
if (targetNode.offsetTop < node.scrollTop) {
this.scrollTo(targetNode.offsetTop - 50);
}
const offsetBottom = targetNode.offsetTop + targetNode.offsetHeight - node.scrollTop - node.offsetHeight;
if (offsetBottom > 0) {
this.scrollBy(offsetBottom);
}
},
/**
* If the viewport is resized and already at the bottom then stay at the bottom.
*/
handleResize() {
if (this.isAtBottom()) {
this.scrollToBottom();
}
},
/**
* Handle all scroll events. This can be triggerd by the user of by the other
* methods that set the scrollTop.
*/
handleScroll() {
const {messages} = this.state;
// Don't even trigger this if there are no messages
// and prevent corrupting this.state.dimensions when full screen'd
if (messages.length === 0 || this.isWebKitFullscreen()) return;
const node = this.getScrollerDOMNode();
// At the top of the scroller node and its larger than the viewport.
if (node.scrollTop === 0 && node.scrollHeight > node.offsetHeight) {
if (messages.hasMoreBefore && !messages.loadingMore) {
this.loadMore();
}
}
const {channel} = this.props;
// If at the bottom then clear the dimensions.
/* Explanation for the '+ 2'
* The browser isn't properly identifying that it is scrolledToBottom, whether
* zoom levels are involved or not in some cases. It appears that in some cases there is an
* off-by-one error in the equality check of scrollHeight == scrollTop+offsetHeight. To solve this
* issue, I change the equality check to a less-than-or-equal check and add a +1 for the cases where
* this can occur. Furthermore, when the browser is set to an alternate zoom level,
* scrollTop changes from an int to a float, while the other two values remain ints. Rather than
* using ceil, I just add an additional +1 (= +2 total) since this is hot code.
* These changes appear to fix the scrolledToBottom detection at only the cost of mistaking it when
* the user is 2px away from the bottom, which is both unnoticeable and unlikely to occur.
*/
if (node.scrollHeight <= node.scrollTop + node.offsetHeight + 2) {
if (messages.hasMoreAfter && !messages.loadingMore) {
this.loadMore(true);
} else if (!messages.hasMoreAfter) {
DimensionActionCreators.clearChannelDimensions(channel.id);
}
} else {
// Otherwise keep track of the current dimensions to use to offset calculation.
DimensionActionCreators.updateChannelDimensions(channel.id, node.scrollTop, node.scrollHeight, node.offsetHeight);
}
},
handleScrollToNewMessages() {
MessageActionCreators.jumpToMessage(this.props.channel.id, this.state.ackMessageId, false, 'Mark As Read');
},
handleAck() {
ReadStateActionCreators.ack(this.props.channel.id);
},
handleReveal(messageId: ?string) {
MessageActionCreators.revealMessage(this.props.channel.id, messageId);
},
});
export default Messages;
// WEBPACK FOOTER //
// ./discord_app/components/Messages.js