890 lines
30 KiB
JavaScript
Executable file
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
|