diff options
Diffstat (limited to 'web/react/components')
-rw-r--r-- | web/react/components/edit_channel_purpose_modal.jsx | 9 | ||||
-rw-r--r-- | web/react/components/post_attachment.jsx | 295 | ||||
-rw-r--r-- | web/react/components/post_attachment_list.jsx | 32 | ||||
-rw-r--r-- | web/react/components/post_body.jsx | 4 | ||||
-rw-r--r-- | web/react/components/post_body_additional_content.jsx | 56 | ||||
-rw-r--r-- | web/react/components/search_autocomplete.jsx | 157 |
6 files changed, 503 insertions, 50 deletions
diff --git a/web/react/components/edit_channel_purpose_modal.jsx b/web/react/components/edit_channel_purpose_modal.jsx index 4cb96a3ff..65e8183de 100644 --- a/web/react/components/edit_channel_purpose_modal.jsx +++ b/web/react/components/edit_channel_purpose_modal.jsx @@ -3,6 +3,8 @@ const AsyncClient = require('../utils/async_client.jsx'); const Client = require('../utils/client.jsx'); +const Utils = require('../utils/utils.jsx'); + const Modal = ReactBootstrap.Modal; export default class EditChannelPurposeModal extends React.Component { @@ -75,11 +77,6 @@ export default class EditChannelPurposeModal extends React.Component { title = <span>{'Edit Purpose for '}<span className='name'>{this.props.channel.display_name}</span></span>; } - let channelTerm = 'Channel'; - if (this.props.channel.channelType === 'P') { - channelTerm = 'Group'; - } - return ( <Modal className='modal-edit-channel-purpose' @@ -93,7 +90,7 @@ export default class EditChannelPurposeModal extends React.Component { </Modal.Title> </Modal.Header> <Modal.Body> - <p>{`Describe how this ${channelTerm} should be used.`}</p> + <p>{`Describe how this ${Utils.getChannelTerm(this.props.channel.channelType)} should be used.`}</p> <textarea ref='purpose' className='form-control no-resize' diff --git a/web/react/components/post_attachment.jsx b/web/react/components/post_attachment.jsx new file mode 100644 index 000000000..2d6b47f03 --- /dev/null +++ b/web/react/components/post_attachment.jsx @@ -0,0 +1,295 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const TextFormatting = require('../utils/text_formatting.jsx'); + +export default class PostAttachment extends React.Component { + constructor(props) { + super(props); + + this.getFieldsTable = this.getFieldsTable.bind(this); + this.getInitState = this.getInitState.bind(this); + this.shouldCollapse = this.shouldCollapse.bind(this); + this.toggleCollapseState = this.toggleCollapseState.bind(this); + } + + componentDidMount() { + $(this.refs.attachment).on('click', '.attachment-link-more', this.toggleCollapseState); + } + + componentWillUnmount() { + $(this.refs.attachment).off('click', '.attachment-link-more', this.toggleCollapseState); + } + + componentWillMount() { + this.setState(this.getInitState()); + } + + getInitState() { + const shouldCollapse = this.shouldCollapse(); + const text = TextFormatting.formatText(this.props.attachment.text || ''); + const uncollapsedText = text + (shouldCollapse ? '<a class="attachment-link-more" href="#">▲ collapse text</a>' : ''); + const collapsedText = shouldCollapse ? this.getCollapsedText() : text; + + return { + shouldCollapse, + collapsedText, + uncollapsedText, + text: shouldCollapse ? collapsedText : uncollapsedText, + collapsed: shouldCollapse + }; + } + + toggleCollapseState(e) { + e.preventDefault(); + + let state = this.state; + state.text = state.collapsed ? state.uncollapsedText : state.collapsedText; + state.collapsed = !state.collapsed; + this.setState(state); + } + + shouldCollapse() { + return (this.props.attachment.text.match(/\n/g) || []).length >= 5 || this.props.attachment.text.length > 700; + } + + getCollapsedText() { + let text = this.props.attachment.text || ''; + if ((text.match(/\n/g) || []).length >= 5) { + text = text.split('\n').splice(0, 5).join('\n'); + } else if (text.length > 700) { + text = text.substr(0, 700); + } + + return TextFormatting.formatText(text) + '<a class="attachment-link-more" href="#">▼ read more</a>'; + } + + getFieldsTable() { + const fields = this.props.attachment.fields; + if (!fields || !fields.length) { + return ''; + } + + const compactTable = fields.filter((field) => field.short).length > 0; + let tHead; + let tBody; + + if (compactTable) { + let headerCols = []; + let bodyCols = []; + + fields.forEach((field, i) => { + headerCols.push( + <th + className='attachment___field-caption' + key={'attachment__field-caption-' + i} + > + {field.title} + </th> + ); + bodyCols.push( + <td + className='attachment___field' + key={'attachment__field-' + i} + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}} + > + </td> + ); + }); + + tHead = ( + <tr> + {headerCols} + </tr> + ); + tBody = ( + <tr> + {bodyCols} + </tr> + ); + } else { + tBody = []; + + fields.forEach((field, i) => { + tBody.push( + <tr key={'attachment__field-' + i}> + <td + className='attachment___field-caption' + > + {field.title} + </td> + <td + className='attachment___field' + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(field.value || '')}} + > + </td> + </tr> + ); + }); + } + + return ( + <table + className='attachment___fields' + > + <thead> + {tHead} + </thead> + <tbody> + {tBody} + </tbody> + </table> + ); + } + + render() { + const data = this.props.attachment; + + let preText; + if (data.pretext) { + preText = ( + <div + className='attachment__thumb-pretext' + dangerouslySetInnerHTML={{__html: TextFormatting.formatText(data.pretext)}} + > + </div> + ); + } + + let author = []; + if (data.author_name || data.author_icon) { + if (data.author_icon) { + author.push( + <img + className='attachment__author-icon' + src={data.author_icon} + key={'attachment__author-icon'} + height='14' + width='14' + /> + ); + } + if (data.author_name) { + author.push( + <span + className='attachment__author-name' + key={'attachment__author-name'} + > + {data.author_name} + </span> + ); + } + } + if (data.author_link) { + author = ( + <a + href={data.author_link} + target='_blank' + > + {author} + </a> + ); + } + + let title; + if (data.title) { + if (data.title_link) { + title = ( + <h1 + className='attachment__title' + > + <a + className='attachment__title-link' + href={data.title_link} + target='_blank' + > + {data.title} + </a> + </h1> + ); + } else { + title = ( + <h1 + className='attachment__title' + > + {data.title} + </h1> + ); + } + } + + let text; + if (data.text) { + text = ( + <div + className='attachment__text' + dangerouslySetInnerHTML={{__html: this.state.text}} + > + </div> + ); + } + + let image; + if (data.image_url) { + image = ( + <img + className='attachment__image' + src={data.image_url} + /> + ); + } + + let thumb; + if (data.thumb_url) { + thumb = ( + <div + className='attachment__thumb-container' + > + <img + src={data.thumb_url} + /> + </div> + ); + } + + const fields = this.getFieldsTable(); + + let useBorderStyle; + if (data.color && data.color[0] === '#') { + useBorderStyle = {borderLeftColor: data.color}; + } + + return ( + <div + className='attachment' + ref='attachment' + > + {preText} + <div className='attachment__content'> + <div + className={useBorderStyle ? 'clearfix attachment__container' : 'clearfix attachment__container attachment__container--' + data.color} + style={useBorderStyle} + > + {author} + {title} + <div> + <div + className={thumb ? 'attachment__body' : 'attachment__body attachment__body--no_thumb'} + > + {text} + {image} + {fields} + </div> + {thumb} + <div style={{clear: 'both'}}></div> + </div> + </div> + </div> + </div> + ); + } +} + +PostAttachment.propTypes = { + attachment: React.PropTypes.object.isRequired +};
\ No newline at end of file diff --git a/web/react/components/post_attachment_list.jsx b/web/react/components/post_attachment_list.jsx new file mode 100644 index 000000000..03b866656 --- /dev/null +++ b/web/react/components/post_attachment_list.jsx @@ -0,0 +1,32 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const PostAttachment = require('./post_attachment.jsx'); + +export default class PostAttachmentList extends React.Component { + constructor(props) { + super(props); + } + + render() { + let content = []; + this.props.attachments.forEach((attachment, i) => { + content.push( + <PostAttachment + attachment={attachment} + key={'att_' + i} + /> + ); + }); + + return ( + <div className='attachment_list'> + {content} + </div> + ); + } +} + +PostAttachmentList.propTypes = { + attachments: React.PropTypes.array.isRequired +}; diff --git a/web/react/components/post_body.jsx b/web/react/components/post_body.jsx index e4094daf3..5a157b792 100644 --- a/web/react/components/post_body.jsx +++ b/web/react/components/post_body.jsx @@ -7,6 +7,7 @@ const Utils = require('../utils/utils.jsx'); const Constants = require('../utils/constants.jsx'); const TextFormatting = require('../utils/text_formatting.jsx'); const twemoji = require('twemoji'); +const PostBodyAdditionalContent = require('./post_body_additional_content.jsx'); export default class PostBody extends React.Component { constructor(props) { @@ -331,6 +332,9 @@ export default class PostBody extends React.Component { dangerouslySetInnerHTML={{__html: TextFormatting.formatText(this.state.message)}} /> </div> + <PostBodyAdditionalContent + post={post} + /> {fileAttachmentHolder} {embed} </div> diff --git a/web/react/components/post_body_additional_content.jsx b/web/react/components/post_body_additional_content.jsx new file mode 100644 index 000000000..8189ba2d3 --- /dev/null +++ b/web/react/components/post_body_additional_content.jsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const PostAttachmentList = require('./post_attachment_list.jsx'); + +export default class PostBodyAdditionalContent extends React.Component { + constructor(props) { + super(props); + + this.getSlackAttachment = this.getSlackAttachment.bind(this); + this.getComponent = this.getComponent.bind(this); + } + + componentWillMount() { + this.setState({type: this.props.post.type, shouldRender: Boolean(this.props.post.type)}); + } + + getSlackAttachment() { + const attachments = this.props.post.props && this.props.post.props.attachments || []; + return ( + <PostAttachmentList + key={'post_body_additional_content' + this.props.post.id} + attachments={attachments} + /> + ); + } + + getComponent() { + switch (this.state.type) { + case 'slack_attachment': + return this.getSlackAttachment(); + } + } + + render() { + let content = []; + + if (this.state.shouldRender) { + const component = this.getComponent(); + + if (component) { + content = component; + } + } + + return ( + <div> + {content} + </div> + ); + } +} + +PostBodyAdditionalContent.propTypes = { + post: React.PropTypes.object.isRequired +};
\ No newline at end of file diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index 03e14ec49..736919697 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -3,14 +3,15 @@ const ChannelStore = require('../stores/channel_store.jsx'); const KeyCodes = require('../utils/constants.jsx').KeyCodes; +const Popover = ReactBootstrap.Popover; const UserStore = require('../stores/user_store.jsx'); const Utils = require('../utils/utils.jsx'); +const Constants = require('../utils/constants.jsx'); const patterns = new Map([ ['channels', /\b(?:in|channel):\s*(\S*)$/i], ['users', /\bfrom:\s*(\S*)$/i] ]); -const Popover = ReactBootstrap.Popover; export default class SearchAutocomplete extends React.Component { constructor(props) { @@ -22,8 +23,13 @@ export default class SearchAutocomplete extends React.Component { this.handleKeyDown = this.handleKeyDown.bind(this); this.completeWord = this.completeWord.bind(this); + this.getSelection = this.getSelection.bind(this); + this.scrollToItem = this.scrollToItem.bind(this); this.updateSuggestions = this.updateSuggestions.bind(this); + this.renderChannelSuggestion = this.renderChannelSuggestion.bind(this); + this.renderUserSuggestion = this.renderUserSuggestion.bind(this); + this.state = { show: false, mode: '', @@ -37,9 +43,18 @@ export default class SearchAutocomplete extends React.Component { $(document).on('click', this.handleDocumentClick); } - componentDidUpdate() { - $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').perfectScrollbar(); - $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content').css('max-height', $(window).height() - 200); + componentDidUpdate(prevProps, prevState) { + const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); + + if (this.state.show) { + if (!prevState.show) { + content.perfectScrollbar(); + content.css('max-height', $(window).height() - 200); + } + + // keep the keyboard selection visible when scrolling + this.scrollToItem(this.getSelection()); + } } componentWillUnmount() { @@ -51,7 +66,7 @@ export default class SearchAutocomplete extends React.Component { } handleDocumentClick(e) { - const container = $(ReactDOM.findDOMNode(this.refs.container)); + const container = $(ReactDOM.findDOMNode(this.refs.searchPopover)); if (!(container.is(e.target) || container.has(e.target).length > 0)) { this.setState({ @@ -111,15 +126,7 @@ export default class SearchAutocomplete extends React.Component { } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) { e.preventDefault(); - this.completeSelectedWord(); - } - } - - completeSelectedWord() { - if (this.state.mode === 'channels') { - this.completeWord(this.state.suggestions[this.state.selection].name); - } else if (this.state.mode === 'users') { - this.completeWord(this.state.suggestions[this.state.selection].username); + this.completeWord(this.getSelection()); } } @@ -135,6 +142,40 @@ export default class SearchAutocomplete extends React.Component { }); } + getSelection() { + if (this.state.mode === 'channels') { + return this.state.suggestions[this.state.selection].name; + } else if (this.state.mode === 'users') { + return this.state.suggestions[this.state.selection].username; + } + + return ''; + } + + scrollToItem(itemName) { + const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); + const visibleContentHeight = content[0].clientHeight; + const actualContentHeight = content[0].scrollHeight; + + if (this.state.suggestions.length > 0 && visibleContentHeight < actualContentHeight) { + const contentTop = content.scrollTop(); + const contentTopPadding = parseInt(content.css('padding-top'), 10); + const contentBottomPadding = parseInt(content.css('padding-top'), 10); + + const item = $(this.refs[itemName]); + const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); + const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10); + + if (itemTop - contentTopPadding < contentTop) { + // the item is off the top of the visible space + content.scrollTop(itemTop - contentTopPadding); + } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { + // the item has gone off the bottom of the visible space + content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); + } + } + } + updateSuggestions(mode, filter) { let suggestions = []; @@ -193,6 +234,46 @@ export default class SearchAutocomplete extends React.Component { }); } + renderChannelSuggestion(channel) { + let className = 'search-autocomplete__item'; + if (channel.name === this.getSelection()) { + className += ' selected'; + } + + return ( + <div + key={channel.name} + ref={channel.name} + onClick={this.handleClick.bind(this, channel.name)} + className={className} + > + {channel.name} + </div> + ); + } + + renderUserSuggestion(user) { + let className = 'search-autocomplete__item'; + if (user.username === this.getSelection()) { + className += ' selected'; + } + + return ( + <div + key={user.username} + ref={user.username} + onClick={this.handleClick.bind(this, user.username)} + className={className} + > + <img + className='profile-img rounded' + src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} + /> + {user.username} + </div> + ); + } + render() { if (!this.state.show || this.state.suggestions.length === 0) { return null; @@ -201,45 +282,33 @@ export default class SearchAutocomplete extends React.Component { let suggestions = []; if (this.state.mode === 'channels') { - suggestions = this.state.suggestions.map((channel, index) => { - let className = 'search-autocomplete__item'; - if (this.state.selection === index) { - className += ' selected'; - } - - return ( + const publicChannels = this.state.suggestions.filter((channel) => channel.type === Constants.OPEN_CHANNEL); + if (publicChannels.length > 0) { + suggestions.push( <div - key={channel.name} - ref={channel.name} - onClick={this.handleClick.bind(this, channel.name)} - className={className} + key='public-channel-divider' + className='search-autocomplete__divider' > - {channel.name} + {'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'} </div> ); - }); - } else if (this.state.mode === 'users') { - suggestions = this.state.suggestions.map((user, index) => { - let className = 'search-autocomplete__item'; - if (this.state.selection === index) { - className += ' selected'; - } + suggestions = suggestions.concat(publicChannels.map(this.renderChannelSuggestion)); + } - return ( + const privateChannels = this.state.suggestions.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); + if (privateChannels.length > 0) { + suggestions.push( <div - key={user.username} - ref={user.username} - onClick={this.handleClick.bind(this, user.username)} - className={className} + key='private-channel-divider' + className='search-autocomplete__divider' > - <img - className='profile-img rounded' - src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} - /> - {user.username} + {'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'} </div> ); - }); + suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion)); + } + } else if (this.state.mode === 'users') { + suggestions = this.state.suggestions.map(this.renderUserSuggestion); } return ( |