589 lines
18 KiB
JavaScript
Executable file
589 lines
18 KiB
JavaScript
Executable file
import React from 'react';
|
|
import PureRenderMixin from 'react-addons-pure-render-mixin';
|
|
import classNames from 'classnames';
|
|
import Helmet from 'react-helmet';
|
|
import ChannelMembers from './ChannelMembers';
|
|
import Messages from './Messages';
|
|
import TypingUsers from './TypingUsers';
|
|
import Status from './Status';
|
|
import ChannelContextMenu from './contextmenus/ChannelContextMenu';
|
|
import UserContextMenu from './contextmenus/UserContextMenu';
|
|
import ContextMenu from './common/ContextMenu';
|
|
import InputAutosize from './common/InputAutosize';
|
|
import DMVerification from './DMVerification';
|
|
import HeaderToolbar from './HeaderToolbar';
|
|
import GuildVerification from './GuildVerification';
|
|
import ChannelMuteButton from './ChannelMuteButton';
|
|
import PrivateChannelRecipientsInviteButton from './PrivateChannelRecipientsInvite';
|
|
import PrivateChannelRecipients from './PrivateChannelRecipients';
|
|
import PrivateChannelCall from './calls/PrivateChannelCall';
|
|
import PrivateChannelCallButton from './calls/PrivateChannelCallButton';
|
|
import SearchResults from './SearchResults';
|
|
import ChannelMembersButton from './ChannelMembersButton';
|
|
import ChannelPinsButton from './ChannelPinsButton';
|
|
import ChannelTextArea from './ChannelTextArea';
|
|
import ChannelRecord from '../records/ChannelRecord';
|
|
import ChannelStore from '../stores/ChannelStore';
|
|
import SelectedChannelStore from '../stores/SelectedChannelStore';
|
|
import SelectedGuildStore from '../stores/SelectedGuildStore';
|
|
import GuildStore from '../stores/GuildStore';
|
|
import EditMessageStore from '../stores/EditMessageStore';
|
|
import ChannelSectionStore from '../stores/ChannelSectionStore';
|
|
import GuildMemberStore from '../stores/GuildMemberStore';
|
|
import MessageStore from '../stores/MessageStore';
|
|
import ModalStore from '../stores/ModalStore';
|
|
import UserStore from '../stores/UserStore';
|
|
import InProgressTextStore from '../stores/InProgressTextStore';
|
|
import SearchStore from '../stores/SearchStore';
|
|
import VideoThemeStore from '../stores/VideoThemeStore';
|
|
import PrivateChannelCallStore from '../stores/views/PrivateChannelCallStore';
|
|
import Flux from '../lib/flux';
|
|
import MarkupUtils from '../utils/MarkupUtils';
|
|
import TypingUtils from '../utils/TypingUtils';
|
|
import i18n from '../i18n';
|
|
import MessageUtils from '../utils/MessageUtils';
|
|
import ComponentDispatchMixin from '../mixins/ComponentDispatchMixin';
|
|
import ChannelActionCreators from '../actions/ChannelActionCreators';
|
|
import UserProfileModalActionCreators from '../actions/UserProfileModalActionCreators';
|
|
import ModalActionCreators from '../actions/ModalActionCreators';
|
|
import MessageActionCreators from '../actions/MessageActionCreators';
|
|
import PermissionMixin from '../mixins/PermissionMixin';
|
|
import TutorialIndicator from './common/TutorialIndicator';
|
|
import MarkdownModal from './MarkdownModal';
|
|
import InProgressTextCreators from '../actions/InProgressTextCreators';
|
|
import ChatRestrictionMixin from '../mixins/web/ChatRestrictionMixin';
|
|
import Spinner from './common/Spinner';
|
|
import GuildNSFW from './warnings/GuildNSFW';
|
|
import GuildNSFWAgreeStore from '../stores/GuildNSFWAgreeStore';
|
|
import {
|
|
ChannelSections,
|
|
ContextMenuTypes,
|
|
ChannelTypes,
|
|
ComponentActions,
|
|
MouseButtons,
|
|
ThemeTypes,
|
|
} from '../Constants';
|
|
|
|
const ChannelName = React.createClass({
|
|
handleBlur(e) {
|
|
if (this.props.channel.toString() !== e.currentTarget.value) {
|
|
ChannelActionCreators.setName(this.props.channel.id, e.currentTarget.value);
|
|
}
|
|
},
|
|
|
|
render() {
|
|
return (
|
|
<InputAutosize
|
|
minLen={1}
|
|
maxLen={100}
|
|
className="channel-name"
|
|
value={this.props.channel.toString()}
|
|
onBlur={this.handleBlur}
|
|
/>
|
|
);
|
|
},
|
|
});
|
|
|
|
const ChannelTopicModal = React.createClass({
|
|
renderHeader() {
|
|
return `#${this.props.channel.toString()}`;
|
|
},
|
|
|
|
render() {
|
|
const {channel} = this.props;
|
|
return (
|
|
<MarkdownModal onClose={this.props.onClose} renderHeader={this.renderHeader} selectable>
|
|
{MarkupUtils.parseTopic(channel.topic, true, {channelId: channel.id})}
|
|
</MarkdownModal>
|
|
);
|
|
},
|
|
});
|
|
|
|
const ChannelTopic = React.createClass({
|
|
handleOpenTopic(e) {
|
|
// Don't open when clicking a link.
|
|
const shouldOpen = !(
|
|
e.target.matches('a') ||
|
|
e.target.parentNode.matches('a') ||
|
|
e.target.className == 'highlight' ||
|
|
e.target.parentNode.className == 'highlight'
|
|
);
|
|
|
|
if (shouldOpen) {
|
|
ModalActionCreators.push(ChannelTopicModal, this.props);
|
|
}
|
|
},
|
|
|
|
render() {
|
|
const {channel} = this.props;
|
|
if (channel.topic == null || channel.topic.length == 0) {
|
|
return <div className="no-topic" />;
|
|
}
|
|
if (channel.type === ChannelTypes.GROUP_DM) return null;
|
|
return (
|
|
<div
|
|
className="topic topic-expandable"
|
|
onMouseDown={this.onMouseDown}
|
|
onMouseMove={this.onMouseMove}
|
|
onMouseUp={this.onMouseUp}
|
|
onContextMenu={e => this.props.onContextMenu(e, ContextMenuTypes.CHANNEL_TOPIC)}>
|
|
{MarkupUtils.parseTopic(channel.topic, true, {channelId: channel.id})}
|
|
</div>
|
|
);
|
|
},
|
|
|
|
// Basically these methods are used to detect whether the user is dragging
|
|
// the window or not, to only show the title if they aren't dragging
|
|
onMouseDown() {
|
|
this._mouseDown = true;
|
|
},
|
|
|
|
onMouseMove() {
|
|
if (this._mouseDown) {
|
|
this._mouseDown = false;
|
|
}
|
|
},
|
|
|
|
onMouseUp(e) {
|
|
if (this._mouseDown && e.button !== MouseButtons.SECONDARY) {
|
|
this.handleOpenTopic(e);
|
|
}
|
|
this._mouseDown = false;
|
|
},
|
|
});
|
|
|
|
const ChannelTextAreaForm = React.createClass({
|
|
mixins: [ChatRestrictionMixin, PureRenderMixin],
|
|
|
|
componentWillMount() {
|
|
InProgressTextStore.addChangeListener(this.inProgressTextDidChange);
|
|
},
|
|
|
|
componentWillUnmount() {
|
|
InProgressTextStore.removeChangeListener(this.inProgressTextDidChange);
|
|
},
|
|
|
|
getInitialState() {
|
|
return {
|
|
textValue: InProgressTextStore.getSavedText(this.props.channel.id),
|
|
};
|
|
},
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
if (nextProps.channel.id !== this.props.channel.id) {
|
|
this.inProgressTextDidChange(nextProps);
|
|
}
|
|
},
|
|
|
|
inProgressTextDidChange(props = this.props) {
|
|
const textValue = InProgressTextStore.getSavedText(props.channel.id);
|
|
if (this.state.textValue === textValue && (!this.refs.input || textValue === this.refs.input.getValue())) return;
|
|
|
|
this.setState({textValue}, () => {
|
|
if (this.refs.input && textValue !== this.refs.input.getValue()) {
|
|
this.refs.input.setValue(textValue);
|
|
this.focus();
|
|
}
|
|
});
|
|
},
|
|
|
|
render() {
|
|
const {channel, onResize} = this.props;
|
|
const placeholder = i18n.Messages.TEXTAREA_PLACEHOLDER.format({channel: channel.toString(true)});
|
|
const Verification = channel.isPrivate() ? DMVerification : GuildVerification;
|
|
|
|
return (
|
|
<form>
|
|
<Verification key={channel.id} channel={channel}>
|
|
<TutorialIndicator tutorialId="writing-messages" position={TutorialIndicator.LEFT} offsetY={-50} offsetX={90}>
|
|
<ChannelTextArea
|
|
ref="input"
|
|
channel={channel}
|
|
placeholder={placeholder}
|
|
onChange={this.handleTextareaChange}
|
|
onSubmit={this.handleSendMessage}
|
|
onResize={onResize}
|
|
onKeyDown={this.handleKeyDown}
|
|
defaultValue={this.state.textValue}
|
|
/>
|
|
</TutorialIndicator>
|
|
</Verification>
|
|
<TypingUsers channel={channel} />
|
|
</form>
|
|
);
|
|
},
|
|
|
|
handleTextareaChange() {
|
|
InProgressTextCreators.changeCurrentText(this.props.channel.id, this.refs.input.getValue());
|
|
TypingUtils.typing(this.props.channel.id);
|
|
},
|
|
|
|
handleKeyDown(e, value) {
|
|
// Ensure no modifier keys are also pressed - since those usually coincide
|
|
// with keyboard shortcuts
|
|
if (e.which === 38 /* UP */ && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
if (value.length === 0) {
|
|
e.preventDefault();
|
|
const channel = this.props.channel;
|
|
const lastEditableMessage = MessageStore.getLastEditableMessage(channel.id);
|
|
if (lastEditableMessage) {
|
|
MessageActionCreators.startEditMessage(channel.id, lastEditableMessage.id, lastEditableMessage.content);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
handleSendMessage(content) {
|
|
if (content.length === 0) return false;
|
|
|
|
if (this.applyChatRestrictions(content)) {
|
|
return;
|
|
}
|
|
|
|
const {channel} = this.props;
|
|
TypingUtils.clear(channel.id);
|
|
this.props.handleJumpToChat && this.props.handleJumpToChat();
|
|
MessageActionCreators.sendMessage(channel.id, MessageUtils.parse(channel, content));
|
|
|
|
return true;
|
|
},
|
|
|
|
focus() {
|
|
const input = this.refs['input'];
|
|
if (input != null) {
|
|
input.focus();
|
|
}
|
|
},
|
|
});
|
|
|
|
const AKA = React.createClass({
|
|
mixins: [Flux.StoreListenerMixin(GuildMemberStore), PureRenderMixin],
|
|
|
|
getStateFromStores() {
|
|
return {
|
|
nicknames: GuildMemberStore.getNicknames(this.props.channel.getRecipientId()).join(', '),
|
|
};
|
|
},
|
|
|
|
render() {
|
|
if (this.state.nicknames.length === 0) {
|
|
return <div className="no-topic" />;
|
|
}
|
|
return (
|
|
<div className="topic">
|
|
<span className="aka">AKA</span>
|
|
{this.state.nicknames}
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
|
|
const Channel = React.createClass({
|
|
mixins: [
|
|
Flux.StoreListenerMixin(
|
|
ChannelStore,
|
|
GuildStore,
|
|
SelectedChannelStore,
|
|
SelectedGuildStore,
|
|
EditMessageStore,
|
|
ModalStore,
|
|
ChannelSectionStore,
|
|
SearchStore,
|
|
PrivateChannelCallStore,
|
|
GuildNSFWAgreeStore,
|
|
VideoThemeStore
|
|
),
|
|
PermissionMixin,
|
|
PureRenderMixin,
|
|
ComponentDispatchMixin,
|
|
],
|
|
|
|
propTypes: {
|
|
channel: React.PropTypes.instanceOf(ChannelRecord),
|
|
},
|
|
|
|
getStateFromStores() {
|
|
const selectedChannelId = SelectedChannelStore.getChannelId();
|
|
const editingMessageId = EditMessageStore.getEditingMessageId();
|
|
const selectedGuildId = SelectedGuildStore.getGuildId();
|
|
const participants = PrivateChannelCallStore.getVisibleCallParticipants();
|
|
return {
|
|
channel: ChannelStore.getChannel(selectedChannelId),
|
|
isEditing: editingMessageId != null,
|
|
hasModalOpen: ModalStore.hasModalOpen(),
|
|
section: ChannelSectionStore.getSection(),
|
|
guild: GuildStore.getGuild(selectedGuildId),
|
|
searchId: SearchStore.getCurrentSearchId(),
|
|
hasCall: participants && participants.length > 0,
|
|
nsfwAgree: GuildNSFWAgreeStore.didAgree(selectedGuildId),
|
|
theme: VideoThemeStore.theme,
|
|
};
|
|
},
|
|
|
|
getSubscriptions() {
|
|
return {
|
|
[ComponentActions.TEXTAREA_FOCUS]: this.handleInputFocus,
|
|
};
|
|
},
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
if (
|
|
(this.state.isEditing !== prevState.isEditing || this.state.hasModalOpen !== prevState.hasModalOpen) &&
|
|
(this.state.isEditing == false && this.state.hasModalOpen == false)
|
|
) {
|
|
this.handleInputFocus();
|
|
}
|
|
|
|
if (
|
|
this.state.channel &&
|
|
prevState.channel &&
|
|
this.state.channel.id !== prevState.channel.id &&
|
|
this.state.topicExpanded
|
|
) {
|
|
this.setState({topicExpanded: false});
|
|
}
|
|
|
|
if (this.state.section !== prevState.section) {
|
|
this.handleResize();
|
|
}
|
|
},
|
|
|
|
handleInputFocus() {
|
|
const input = this.refs['input'];
|
|
if (input != null) {
|
|
input.focus();
|
|
}
|
|
},
|
|
|
|
handleOpenProfile() {
|
|
UserProfileModalActionCreators.open(this.state.channel.getRecipientId());
|
|
},
|
|
|
|
handleChannelContextMenu(event, type) {
|
|
const {guild, channel} = this.state;
|
|
ContextMenu.openContextMenu(event, props =>
|
|
<ChannelContextMenu {...props} type={type} channel={channel} guild={guild} />
|
|
);
|
|
},
|
|
|
|
handleUserContextMenu(event) {
|
|
const {channel} = this.state;
|
|
ContextMenu.openContextMenu(event, props =>
|
|
<UserContextMenu
|
|
{...props}
|
|
type={ContextMenuTypes.USER_CHANNEL_TITLE}
|
|
user={UserStore.getUser(channel.getRecipientId())}
|
|
selected={true}
|
|
channelId={channel.id}
|
|
/>
|
|
);
|
|
},
|
|
|
|
renderTitle() {
|
|
const {channel} = this.state;
|
|
switch (channel.type) {
|
|
case ChannelTypes.DM:
|
|
const userId = channel.getRecipientId();
|
|
return (
|
|
<div className="title" onContextMenu={this.handleUserContextMenu}>
|
|
<a className="channel-name channel-private" onClick={this.handleOpenProfile}>{channel.toString()}</a>
|
|
<Status userId={userId} />
|
|
</div>
|
|
);
|
|
case ChannelTypes.GROUP_DM:
|
|
if (channel.isManaged()) {
|
|
return (
|
|
<div className="title channel-group-dm-managed">
|
|
<a className="channel-name channel-private">{channel.toString()}</a>
|
|
</div>
|
|
);
|
|
} else {
|
|
return (
|
|
<div className="title channel-group-dm">
|
|
<ChannelName key={`channel-${channel.id}`} channel={channel} />
|
|
</div>
|
|
);
|
|
}
|
|
case ChannelTypes.GUILD_TEXT:
|
|
return (
|
|
<div className="title" onContextMenu={e => this.handleChannelContextMenu(e, ContextMenuTypes.CHANNEL_TITLE)}>
|
|
<span className="channel-name">{channel.toString()}</span>
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
|
|
renderTopic() {
|
|
const {channel, guild} = this.state;
|
|
switch (channel.type) {
|
|
case ChannelTypes.DM:
|
|
return <AKA key={channel.id} channel={channel} />;
|
|
case ChannelTypes.GUILD_TEXT:
|
|
return (
|
|
<ChannelTopic
|
|
key={channel.id}
|
|
channel={channel}
|
|
guild={guild}
|
|
onContextMenu={this.handleChannelContextMenu}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
|
|
renderToolbar() {
|
|
const {channel} = this.state;
|
|
let callButton;
|
|
let muteButton;
|
|
let membersButton;
|
|
let recipientsButton;
|
|
switch (channel.type) {
|
|
case ChannelTypes.DM:
|
|
callButton = <PrivateChannelCallButton channel={channel} clickRecipientButton={this.clickRecipientButton} />;
|
|
recipientsButton = (
|
|
<PrivateChannelRecipientsInviteButton
|
|
channel={channel}
|
|
ref={this.getRecipientButtonRef}
|
|
tooltip={i18n.Messages.GROUP_DM_ADD_FRIENDS}
|
|
/>
|
|
);
|
|
callButton = <PrivateChannelCallButton channel={channel} clickRecipientButton={this.clickRecipientButton} />;
|
|
break;
|
|
case ChannelTypes.GROUP_DM:
|
|
membersButton = <ChannelMembersButton />;
|
|
|
|
if (!channel.isManaged()) {
|
|
recipientsButton = (
|
|
<PrivateChannelRecipientsInviteButton channel={channel} tooltip={i18n.Messages.GROUP_DM_ADD_FRIENDS} />
|
|
);
|
|
}
|
|
|
|
callButton = <PrivateChannelCallButton channel={channel} />;
|
|
break;
|
|
case ChannelTypes.GUILD_TEXT:
|
|
membersButton = <ChannelMembersButton />;
|
|
muteButton = <ChannelMuteButton channel={channel} />;
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<HeaderToolbar>
|
|
{callButton}
|
|
{muteButton}
|
|
<ChannelPinsButton />
|
|
{membersButton}
|
|
{recipientsButton}
|
|
</HeaderToolbar>
|
|
);
|
|
},
|
|
|
|
renderCall() {
|
|
const {channel} = this.state;
|
|
switch (channel.type) {
|
|
case ChannelTypes.DM:
|
|
return <PrivateChannelCall key={channel.id} channel={channel} onResize={this.handleResize} />;
|
|
case ChannelTypes.GROUP_DM:
|
|
return <PrivateChannelCall key={channel.id} channel={channel} onResize={this.handleResize} />;
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
|
|
renderChat() {
|
|
const {channel, nsfwAgree, guild} = this.state;
|
|
|
|
if (!nsfwAgree && channel.isNSFW()) {
|
|
return <GuildNSFW guild={guild} />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex-spacer flex-vertical" style={{position: 'relative'}}>
|
|
<Messages key={channel.id} ref="messages" channel={channel} />
|
|
<ChannelTextAreaForm
|
|
ref="input"
|
|
channel={channel}
|
|
onResize={this.handleResize}
|
|
handleJumpToPresent={this.handleJumpToPresent}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
|
|
renderSidebar() {
|
|
const {searchId, channel, section} = this.state;
|
|
|
|
if (section === ChannelSections.MEMBERS) {
|
|
switch (channel.type) {
|
|
case ChannelTypes.GROUP_DM:
|
|
return <PrivateChannelRecipients channel={channel} />;
|
|
case ChannelTypes.GUILD_TEXT:
|
|
return <ChannelMembers channel={channel} />;
|
|
}
|
|
} else if (section === ChannelSections.SEARCH) {
|
|
return <SearchResults searchId={searchId} />;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
renderEmptyChannel() {
|
|
return (
|
|
<div className="chat flex-vertical flex-spacer empty">
|
|
<Spinner />
|
|
</div>
|
|
);
|
|
},
|
|
|
|
render() {
|
|
const {channel, hasCall, theme} = this.state;
|
|
if (!channel) {
|
|
return this.renderEmptyChannel();
|
|
}
|
|
return (
|
|
<div className={classNames('chat flex-vertical flex-spacer', {private: channel.isPrivate()})}>
|
|
<div className={classNames('title-wrap', {call: hasCall, 'title-wrap-dark': theme === ThemeTypes.DARK})}>
|
|
<Helmet title={channel.toString(true)} />
|
|
{this.renderTitle()}
|
|
{this.renderTopic()}
|
|
{this.renderToolbar()}
|
|
</div>
|
|
{this.renderCall()}
|
|
<div className="content flex-spacer flex-horizontal">
|
|
{this.renderChat()}
|
|
{this.renderSidebar()}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
|
|
handleJumpToPresent() {
|
|
if (MessageStore.getMessages(this.state.channel.id).hasMoreAfter) {
|
|
this.refs.messages.jumpToPresent();
|
|
}
|
|
},
|
|
|
|
handleResize() {
|
|
const messages = this.refs['messages'];
|
|
if (messages != null) {
|
|
messages.handleResize();
|
|
}
|
|
},
|
|
|
|
getRecipientButtonRef(button) {
|
|
this._recipientButton = button || null;
|
|
},
|
|
|
|
clickRecipientButton() {
|
|
this._recipientButton && this._recipientButton.openFromDisabledCall();
|
|
},
|
|
});
|
|
|
|
export default Channel;
|
|
|
|
|
|
|
|
// WEBPACK FOOTER //
|
|
// ./discord_app/components/Channel.js
|