diff --git a/components/admin_console/admin_console.jsx b/components/admin_console/admin_console.jsx index e415e84a57ca..4c29ef4b1bdd 100644 --- a/components/admin_console/admin_console.jsx +++ b/components/admin_console/admin_console.jsx @@ -14,6 +14,7 @@ import ClientVersionsSettings from 'components/admin_console/client_versions_set import ClusterSettings from 'components/admin_console/cluster_settings.jsx'; import CustomBrandSettings from 'components/admin_console/custom_brand_settings.jsx'; import CustomEmojiSettings from 'components/admin_console/custom_emoji_settings.jsx'; +import CustomGifSettings from 'components/admin_console/custom_gif_settings.jsx'; import DataRetentionSettings from 'components/admin_console/data_retention_settings.jsx'; import DatabaseSettings from 'components/admin_console/database_settings.jsx'; import ElasticsearchSettings from 'components/admin_console/elasticsearch_settings.jsx'; @@ -471,6 +472,11 @@ export default class AdminConsole extends React.Component { component={CustomEmojiSettings} extraProps={extraProps} /> + + + + } + /> + ); + } + + renderSettings() { + return ( + + + } + helpText={ + + } + value={this.state.enableGifPicker} + onChange={this.handleChange} + setByEnv={this.isSetByEnv('ServiceSettings.EnableGifPicker')} + /> + + } + helpText={ + + } + value={this.state.gfycatApiKey} + placeholder='' + onChange={this.handleChange} + setByEnv={this.isSetByEnv('ServiceSettings.GfycatAPIKey')} + /> + + } + helpText={ + + } + value={this.state.gfycatApiSecret} + placeholder='' + onChange={this.handleChange} + setByEnv={this.isSetByEnv('ServiceSettings.GfycatAPISecret')} + /> + + ); + } +} diff --git a/components/create_comment/create_comment.jsx b/components/create_comment/create_comment.jsx index 47b53d327101..b03abae0aa97 100644 --- a/components/create_comment/create_comment.jsx +++ b/components/create_comment/create_comment.jsx @@ -139,6 +139,11 @@ export default class CreateComment extends React.PureComponent { */ enableEmojiPicker: PropTypes.bool.isRequired, + /** + * Set if the gif picker is enabled. + */ + enableGifPicker: PropTypes.bool.isRequired, + /** * The maximum length of a post */ @@ -250,6 +255,29 @@ export default class CreateComment extends React.PureComponent { this.focusTextbox(); } + handleGifClick = (gif) => { + const {draft} = this.state; + + let newMessage = ''; + if (draft.message === '') { + newMessage = gif; + } else if (/\s+$/.test(draft.message)) { + // Check whether there is already a blank at the end of the current message + newMessage = `${draft.message}${gif} `; + } else { + newMessage = `${draft.message} ${gif} `; + } + + this.props.onUpdateCommentDraft({...draft, message: newMessage}); + + this.setState({ + showEmojiPicker: false, + draft: {...draft, message: newMessage}, + }); + + this.focusTextbox(); + } + handlePostError = (postError) => { this.setState({postError}); } @@ -607,6 +635,8 @@ export default class CreateComment extends React.PureComponent { target={this.getCreateCommentControls} onHide={this.hideEmojiPicker} onEmojiClick={this.handleEmojiClick} + onGifClick={this.handleGifClick} + enableGifPicker={this.props.enableGifPicker} rightOffset={15} topOffset={55} /> diff --git a/components/create_comment/index.js b/components/create_comment/index.js index 8d1e2f5c4676..c7a23ff15cd9 100644 --- a/components/create_comment/index.js +++ b/components/create_comment/index.js @@ -38,6 +38,7 @@ function mapStateToProps(state, ownProps) { const config = getConfig(state); const enableConfirmNotificationsToChannel = config.EnableConfirmNotificationsToChannel === 'true'; const enableEmojiPicker = config.EnableEmojiPicker === 'true'; + const enableGifPicker = config.EnableGifPicker === 'true'; return { draft, @@ -49,6 +50,7 @@ function mapStateToProps(state, ownProps) { readOnlyChannel: !isCurrentUserSystemAdmin(state) && config.ExperimentalTownSquareIsReadOnly === 'true' && channel.name === Constants.DEFAULT_CHANNEL, enableConfirmNotificationsToChannel, enableEmojiPicker, + enableGifPicker, maxPostSize: parseInt(config.MaxPostSize, 10) || Constants.DEFAULT_CHARACTER_LIMIT, }; } diff --git a/components/create_post/create_post.jsx b/components/create_post/create_post.jsx index 3f9b8722f805..04d0212fc26a 100644 --- a/components/create_post/create_post.jsx +++ b/components/create_post/create_post.jsx @@ -120,6 +120,11 @@ export default class CreatePost extends React.Component { */ enableEmojiPicker: PropTypes.bool.isRequired, + /** + * Whether to show the gif picker. + */ + enableGifPicker: PropTypes.bool.isRequired, + /** * Whether to check with the user before notifying the whole channel. */ @@ -739,6 +744,17 @@ export default class CreatePost extends React.Component { this.focusTextbox(); } + handleGifClick = (gif) => { + if (this.state.message === '') { + this.setState({message: gif}); + } else { + const newMessage = (/\s+$/.test(this.state.message)) ? this.state.message + gif : this.state.message + ' ' + gif; + this.setState({message: newMessage}); + } + this.setState({showEmojiPicker: false}); + this.focusTextbox(); + } + createTutorialTip() { const screens = []; @@ -879,6 +895,8 @@ export default class CreatePost extends React.Component { target={this.getCreatePostControls} onHide={this.hideEmojiPicker} onEmojiClick={this.handleEmojiClick} + onGifClick={this.handleGifClick} + enableGifPicker={this.props.enableGifPicker} rightOffset={15} topOffset={-7} /> diff --git a/components/create_post/index.js b/components/create_post/index.js index feb5f312cd2b..98ef29c1478d 100644 --- a/components/create_post/index.js +++ b/components/create_post/index.js @@ -49,6 +49,7 @@ function mapStateToProps() { const enableTutorial = config.EnableTutorial === 'true'; const tutorialStep = getInt(state, Preferences.TUTORIAL_STEP, getCurrentUserId(state), TutorialSteps.FINISHED); const enableEmojiPicker = config.EnableEmojiPicker === 'true'; + const enableGifPicker = config.EnableGifPicker === 'true'; const enableConfirmNotificationsToChannel = config.EnableConfirmNotificationsToChannel === 'true'; const currentUserId = getCurrentUserId(state); const userIsOutOfOffice = getStatusForUserId(state, currentUserId) === UserStatuses.OUT_OF_OFFICE; @@ -70,6 +71,7 @@ function mapStateToProps() { readOnlyChannel: !isCurrentUserSystemAdmin(state) && config.ExperimentalTownSquareIsReadOnly === 'true' && currentChannel.name === Constants.DEFAULT_CHANNEL, canUploadFiles: canUploadFiles(config), enableEmojiPicker, + enableGifPicker, enableConfirmNotificationsToChannel, maxPostSize: parseInt(config.MaxPostSize, 10) || Constants.DEFAULT_CHARACTER_LIMIT, userIsOutOfOffice, diff --git a/components/edit_post_modal/edit_post_modal.jsx b/components/edit_post_modal/edit_post_modal.jsx index 7ce2727a5575..de14a433ce59 100644 --- a/components/edit_post_modal/edit_post_modal.jsx +++ b/components/edit_post_modal/edit_post_modal.jsx @@ -132,6 +132,17 @@ export default class EditPostModal extends React.PureComponent { this.refs.editbox.focus(); } + handleGifClick = (gif) => { + if (this.state.editText === '') { + this.setState({editText: gif}); + } else { + const newMessage = (/\s+$/.test(this.state.editText)) ? this.state.editText + gif : this.state.editText + ' ' + gif; + this.setState({editText: newMessage}); + } + this.setState({showEmojiPicker: false}); + this.refs.editbox.focus(); + } + getEditPostControls = () => { return this.refs.editPostEmoji; } @@ -264,6 +275,8 @@ export default class EditPostModal extends React.PureComponent { target={this.getEditPostControls} onHide={this.hideEmojiPicker} onEmojiClick={this.handleEmojiClick} + onGifClick={this.handleGifClick} + enableGifPicker={this.props.config.EnableGifPicker === 'true'} rightOffset={50} topOffset={-20} /> diff --git a/components/emoji_picker/emoji_picker.jsx b/components/emoji_picker/emoji_picker.jsx index 69e37db4bc7e..8583f35c710f 100644 --- a/components/emoji_picker/emoji_picker.jsx +++ b/components/emoji_picker/emoji_picker.jsx @@ -17,7 +17,7 @@ import EmojiPickerPreview from './components/emoji_picker_preview'; const CATEGORY_SEARCH_RESULTS = 'searchResults'; const EMOJI_HEIGHT = 27; -const EMOJI_CONTAINER_HEIGHT = 300; +const EMOJI_CONTAINER_HEIGHT = 244; const EMOJI_CONTAINER_STYLE = { height: EMOJI_CONTAINER_HEIGHT, }; @@ -112,6 +112,7 @@ export default class EmojiPicker extends React.PureComponent { style: PropTypes.object, rightOffset: PropTypes.number, topOffset: PropTypes.number, + listHeight: PropTypes.number, placement: PropTypes.oneOf(['top', 'bottom', 'left']), onEmojiClick: PropTypes.func.isRequired, customEmojisEnabled: PropTypes.bool, @@ -125,6 +126,7 @@ export default class EmojiPicker extends React.PureComponent { }; static defaultProps = { + listHeight: 245, rightOffset: 0, topOffset: 0, customEmojiPage: 0, @@ -588,11 +590,10 @@ export default class EmojiPicker extends React.PureComponent { } return (
- {this.emojiCategories()} {this.emojiSearch()} + {this.emojiCategories()} {this.emojiCurrentResults()}
diff --git a/components/emoji_picker/emoji_picker_overlay.jsx b/components/emoji_picker/emoji_picker_overlay.jsx index 90ebe01a0e25..63718b667c20 100644 --- a/components/emoji_picker/emoji_picker_overlay.jsx +++ b/components/emoji_picker/emoji_picker_overlay.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {Overlay} from 'react-bootstrap'; -import EmojiPicker from './'; +import EmojiPickerTabs from './emoji_picker_tabs.jsx'; export default class EmojiPickerOverlay extends React.PureComponent { static propTypes = { @@ -13,17 +13,20 @@ export default class EmojiPickerOverlay extends React.PureComponent { container: PropTypes.func, target: PropTypes.func.isRequired, onEmojiClick: PropTypes.func.isRequired, + onGifClick: PropTypes.func, onHide: PropTypes.func.isRequired, rightOffset: PropTypes.number, topOffset: PropTypes.number, spaceRequiredAbove: PropTypes.number, spaceRequiredBelow: PropTypes.number, + enableGifPicker: PropTypes.bool, } // Reasonable defaults calculated from from the center channel static defaultProps = { spaceRequiredAbove: 422, spaceRequiredBelow: 436, + enableGifPicker: false, } constructor(props) { @@ -62,8 +65,10 @@ export default class EmojiPickerOverlay extends React.PureComponent { target={this.props.target} animation={false} > - diff --git a/components/emoji_picker/emoji_picker_tabs.jsx b/components/emoji_picker/emoji_picker_tabs.jsx new file mode 100644 index 000000000000..b0a4feb79e9b --- /dev/null +++ b/components/emoji_picker/emoji_picker_tabs.jsx @@ -0,0 +1,108 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import PropTypes from 'prop-types'; +import React, {PureComponent} from 'react'; +import {Tab, Tabs} from 'react-bootstrap'; + +import GifPicker from 'components/gif_picker/gif_picker.jsx'; +import EmojiIcon from 'components/svg/emoji_icon'; +import GfycatIcon from 'components/svg/gfycat_icon'; + +import EmojiPicker from './'; + +export default class EmojiPickerTabs extends PureComponent { + static propTypes = { + style: PropTypes.object, + rightOffset: PropTypes.number, + topOffset: PropTypes.number, + placement: PropTypes.oneOf(['top', 'bottom', 'left']), + customEmojis: PropTypes.object, + onEmojiClick: PropTypes.func.isRequired, + onGifClick: PropTypes.func, + enableGifPicker: PropTypes.bool, + }; + + static defaultProps = { + rightOffset: 0, + topOffset: 0, + }; + + render() { + let pickerStyle; + if (this.props.style && !(this.props.style.left === 0 || this.props.style.top === 0)) { + if (this.props.placement === 'top' || this.props.placement === 'bottom') { + // Only take the top/bottom position passed by React Bootstrap since we want to be right-aligned + pickerStyle = { + top: this.props.style.top, + bottom: this.props.style.bottom, + right: this.props.rightOffset, + }; + } else { + pickerStyle = {...this.props.style}; + } + } + + if (pickerStyle && pickerStyle.top) { + pickerStyle.top += this.props.topOffset; + } + + let pickerClass = 'emoji-picker'; + if (this.props.placement === 'bottom') { + pickerClass += ' bottom'; + } + + if (this.props.enableGifPicker && typeof this.props.onGifClick != 'undefined') { + return ( + + } + > + + + } + mountOnEnter={true} + unmountOnExit={true} + > + + + + ); + } + return ( +
+ +
+ ); + } +} diff --git a/components/gif_picker/components/App/index.jsx b/components/gif_picker/components/App/index.jsx new file mode 100644 index 000000000000..7dc59c651327 --- /dev/null +++ b/components/gif_picker/components/App/index.jsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import {saveAppProps} from 'mattermost-redux/actions/gifs'; + +import Header from 'components/gif_picker/components/Header'; + +const mapDispatchToProps = ({ + saveAppProps, +}); + +export class App extends PureComponent { + static propTypes = { + appProps: PropTypes.object, + action: PropTypes.string, + onCategories: PropTypes.func, + onSearch: PropTypes.func, + onTrending: PropTypes.func, + children: PropTypes.object, + saveAppProps: PropTypes.func, + authenticateSdk: PropTypes.func, + } + + constructor(props) { + super(props); + const {appProps} = this.props; + this.props.saveAppProps(appProps); + } + + render() { + const { + appProps, + action, + onCategories, + onSearch, + onTrending, + children, + } = this.props; + const appClassName = 'main-container ' + (appProps.appClassName || ''); + return ( +
+
+
+ {children} +
+
+ ); + } +} + +export default connect(null, mapDispatchToProps)(App); diff --git a/components/gif_picker/components/Categories/Categories.scss b/components/gif_picker/components/Categories/Categories.scss new file mode 100644 index 000000000000..7907fc748761 --- /dev/null +++ b/components/gif_picker/components/Categories/Categories.scss @@ -0,0 +1,43 @@ +.categories-container { + position: relative; + font-size: 0; + padding: 0 .2rem; + box-sizing: border-box; + overflow-x: hidden; + height: 292px; + + .category-container { + width: 33.33%; + display: inline-block; + padding: .2rem; + box-sizing: border-box; + } + + .category { + position: relative; + display: inline-block; + width: 100%; + padding-bottom: 75%; + background-size: cover; + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + border-radius: .6rem; + overflow: hidden; + } + + .category-name { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: absolute; + background-color: rgba(0, 0, 0, 0.2); + color: white; + font-size: 1.2rem; + font-weight: bold; + text-align: center; + text-transform: uppercase; + } +} diff --git a/components/gif_picker/components/Categories/index.jsx b/components/gif_picker/components/Categories/index.jsx new file mode 100644 index 000000000000..311ad3285666 --- /dev/null +++ b/components/gif_picker/components/Categories/index.jsx @@ -0,0 +1,170 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import {requestCategoriesList, requestCategoriesListIfNeeded, saveSearchBarText, saveSearchScrollPosition, searchTextUpdate} from 'mattermost-redux/actions/gifs'; +import {changeOpacity, makeStyleFromTheme} from 'mattermost-redux/utils/theme_utils'; + +import {trackEvent} from 'actions/diagnostics_actions.jsx'; +import store from 'stores/redux_store.jsx'; +import Constants from 'utils/constants'; +import * as PostUtils from 'utils/post_utils.jsx'; + +import InfiniteScroll from 'components/gif_picker/components/InfiniteScroll'; + +import './Categories.scss'; + +function mapStateToProps(state) { + return { + ...state.entities.gifs.categories, + ...state.entities.gifs.cache, + appProps: state.entities.gifs.app, + searchText: state.entities.gifs.search.searchText, + searchBarText: state.entities.gifs.search.searchBarText, + hasImageProxy: state.entities.general.config.HasImageProxy, + }; +} + +const mapDispatchToProps = ({ + saveSearchBarText, + saveSearchScrollPosition, + searchTextUpdate, + requestCategoriesList, + requestCategoriesListIfNeeded, +}); + +const getStyle = makeStyleFromTheme((theme) => { + return { + background: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.05), + }, + }; +}); + +export class Categories extends PureComponent { + static propTypes = { + appProps: PropTypes.object, + gifs: PropTypes.object, + hasMore: PropTypes.bool, + onSearch: PropTypes.func, + onTrending: PropTypes.func, + requestCategoriesList: PropTypes.func, + requestCategoriesListIfNeeded: PropTypes.func, + saveSearchBarText: PropTypes.func, + saveSearchScrollPosition: PropTypes.func, + searchTextUpdate: PropTypes.func, + searchBarText: PropTypes.string, + tagsList: PropTypes.array, + hasImageProxy: PropTypes.string, + } + + componentDidMount() { + window.scrollTo(0, 0); + this.props.requestCategoriesListIfNeeded(); + this.sendImpressions(); + } + + sendImpressions = () => { + const {tagsList} = this.props; + const gfycats = tagsList.map((tag) => { + return {gfyId: tag.gfyId}; + }); + + if (gfycats.length) { + trackEvent('gfycat', 'views', {context: 'category_list', count: gfycats.length}); + } + } + + componentWillUnmount() { + this.props.saveSearchScrollPosition(0); + } + + filterTagsList = () => { + const {searchBarText, tagsList} = this.props; + + const substr = searchBarText.toLowerCase().trim().split(/ +/).join(' '); + return tagsList && tagsList.length ? tagsList.filter((tag) => { + if (!searchBarText || tag.tagName.indexOf(substr) !== -1) { + return tag; + } + return ''; + }) : []; + } + + loadMore = () => { + this.props.requestCategoriesList(); + } + + render() { + const prefs = store.getState().entities.preferences.myPreferences; + const theme = 'theme--' in prefs ? JSON.parse(prefs['theme--'].value) : Constants.THEMES.default; + const style = getStyle(theme); + + const {hasMore, tagsList, gifs, onSearch, onTrending, hasImageProxy} = this.props; + + const content = tagsList && tagsList.length ? this.filterTagsList(tagsList).map((item, index) => { + const {tagName, gfyId} = item; + + if (!gifs[gfyId]) { + return null; + } + + const gfyItem = gifs[gfyId]; + const {max1mbGif, avgColor} = gfyItem; + const url = PostUtils.getImageSrc(max1mbGif, hasImageProxy === 'true'); + const searchText = tagName.replace(/\s/g, '-'); + const backgroundImage = {backgroundImage: `url(${url}`}; + const backgroundColor = {backgroundColor: avgColor}; + const props = this.props; + function callback() { + props.searchTextUpdate(tagName); + props.saveSearchBarText(tagName); + if (searchText === 'trending') { + onTrending(); + } else { + onSearch(); + } + } + return ( + +
+
+
{tagName}
+
+
+
+ ); + }) : []; + + return content && content.length ? ( +
+ + {content} + +
+ ) : ( +
+ ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Categories); diff --git a/components/gif_picker/components/Header/Header.scss b/components/gif_picker/components/Header/Header.scss new file mode 100644 index 000000000000..809a6473123c --- /dev/null +++ b/components/gif_picker/components/Header/Header.scss @@ -0,0 +1,35 @@ +.header-container { + z-index: 1; + width: 100%; + + .nav-bar { + border-bottom-width: 1px; + border-bottom-style: solid; + position: relative; + height: 3.5rem; + display: flex; + align-items: center; + justify-content: space-around; + + .ic { + cursor: pointer; + vertical-align: middle; + } + + a { + align-items: center; + display: flex; + flex-grow: 1; + height: 100%; + justify-content: space-around; + + .header-text { + font-family: SFUIDisplay, sans-serif; + font-size: 1.6rem; + font-weight: 500; + padding-left: 1rem; + vertical-align: middle; + } + } + } +} diff --git a/components/gif_picker/components/Header/index.jsx b/components/gif_picker/components/Header/index.jsx new file mode 100644 index 000000000000..2d849a193498 --- /dev/null +++ b/components/gif_picker/components/Header/index.jsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import {saveSearchBarText, searchTextUpdate} from 'mattermost-redux/actions/gifs'; +import {changeOpacity, makeStyleFromTheme} from 'mattermost-redux/utils/theme_utils'; + +import store from 'stores/redux_store.jsx'; +import Constants from 'utils/constants'; + +import constants from 'components/gif_picker/utils/constants'; +import SearchBar from 'components/gif_picker/components/SearchBar'; +import GifTrendingIcon from 'components/svg/gif_trending_icon'; +import GifReactionsIcon from 'components/svg/gif_reactions_icon'; +import './Header.scss'; + +const mapDispatchToProps = ({ + saveSearchBarText, + searchTextUpdate, +}); + +const getStyle = makeStyleFromTheme((theme) => { + return { + background: { + backgroundColor: theme.centerChannelBg, + }, + header: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), + }, + icon: { + fill: changeOpacity(theme.centerChannelColor, 0.3), + }, + iconActive: { + fill: theme.centerChannelColor, + }, + iconHover: { + fill: changeOpacity(theme.centerChannelColor, 0.8), + }, + }; +}); + +export class Header extends PureComponent { + static propTypes = { + action: PropTypes.string, + appProps: PropTypes.object, + saveSearchBarText: PropTypes.func, + searchTextUpdate: PropTypes.func, + } + + constructor(props) { + super(props); + this.state = { + hovering: '', + }; + } + + render() { + const prefs = store.getState().entities.preferences.myPreferences; + const theme = 'theme--' in prefs ? JSON.parse(prefs['theme--'].value) : Constants.THEMES.default; + const style = getStyle(theme); + + return ( +
+ + +
+ ); + } + + renderTabs(props, style) { + const {appProps, onTrending, onCategories} = props; + const {header} = appProps; + return header.tabs.map((tab, index) => { + let link; + if (tab === constants.Tab.TRENDING) { + link = this.renderTab('trending', onTrending, GifTrendingIcon, index, style); + } else if (tab === constants.Tab.REACTIONS) { + link = this.renderTab('reactions', onCategories, GifReactionsIcon, index, style); + } + return link; + }); + } + + renderTab(name, callback, Icon, index, style) { + var props = this.props; + const {action} = props; + function callbackWrapper() { + props.searchTextUpdate(''); + props.saveSearchBarText(''); + callback(); + } + return ( + { + this.setState({hovering: name}); + }} + onMouseOut={() => { + this.setState({hovering: ''}); + }} + style={{cursor: 'pointer'}} + key={index} + > +
+ { + if (this.state.hovering === name) { + return style.iconHover; + } + return action === name ? style.iconActive : style.icon; + })()} + /> +
+
+ ); + } +} + +export default connect(null, mapDispatchToProps)(Header); diff --git a/components/gif_picker/components/InfiniteScroll/index.js b/components/gif_picker/components/InfiniteScroll/index.js new file mode 100644 index 000000000000..07cde5f50118 --- /dev/null +++ b/components/gif_picker/components/InfiniteScroll/index.js @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; + +export default class InfiniteScroll extends PureComponent { + static propTypes = { + children: PropTypes.array, + element: PropTypes.string, + hasMore: PropTypes.bool, + initialLoad: PropTypes.bool, + loader: PropTypes.object, + loadMore: PropTypes.func.isRequired, + pageStart: PropTypes.number, + threshold: PropTypes.number, + useWindow: PropTypes.bool, + isReverse: PropTypes.bool, + containerHeight: PropTypes.number, + scrollPosition: PropTypes.number, + } + + static defaultProps = { + element: 'div', + hasMore: false, + initialLoad: true, + pageStart: 0, + threshold: 250, + useWindow: true, + isReverse: false, + containerHeight: null, + scrollPosition: null, + } + + constructor(props) { + super(props); + + this.scrollListener = this.scrollListener.bind(this); + } + + componentDidMount() { + this.pageLoaded = this.props.pageStart; + this.attachScrollListener(); + this.setScrollPosition(); + } + + componentDidUpdate() { + this.attachScrollListener(); + } + + render() { + const { + children, + element, + hasMore, + initialLoad, // eslint-disable-line no-unused-vars + loader, + loadMore, // eslint-disable-line no-unused-vars + pageStart, // eslint-disable-line no-unused-vars + threshold, // eslint-disable-line no-unused-vars + useWindow, // eslint-disable-line no-unused-vars + isReverse, // eslint-disable-line no-unused-vars + scrollPosition, // eslint-disable-line no-unused-vars + containerHeight, + ...props + } = this.props; + + props.ref = (node) => { + this.scrollComponent = node; + }; + + const elementProps = containerHeight ? {...props, style: {height: containerHeight}} : props; + + return React.createElement(element, elementProps, children, hasMore && (loader || this.defaultLoader)); + } + + calculateTopPosition(el) { + if (!el) { + return 0; + } + return el.offsetTop + this.calculateTopPosition(el.offsetParent); + } + + setScrollPosition() { + const {scrollPosition} = this.props; + if (scrollPosition !== null) { + window.scrollTo(0, scrollPosition); + } + } + + scrollListener() { + const el = this.scrollComponent; + const scrollEl = window; + + let offset; + if (this.props.useWindow) { + var scrollTop = ('pageYOffset' in scrollEl) ? scrollEl.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop; + if (this.props.isReverse) { + offset = scrollTop; + } else { + offset = this.calculateTopPosition(el) + (el.offsetHeight - scrollTop - window.innerHeight); + } + } else if (this.props.isReverse) { + offset = el.parentNode.scrollTop; + } else { + offset = el.scrollHeight - el.parentNode.scrollTop - el.parentNode.clientHeight; + } + + if (offset < Number(this.props.threshold)) { + this.detachScrollListener(); + + // Call loadMore after detachScrollListener to allow for non-async loadMore functions + if (typeof this.props.loadMore === 'function') { + this.props.loadMore(this.pageLoaded += 1); + } + } + } + + attachScrollListener() { + if (!this.props.hasMore) { + return; + } + + let scrollEl = window; + if (this.props.useWindow === false) { + scrollEl = this.scrollComponent.parentNode; + } + + scrollEl.addEventListener('scroll', this.scrollListener); + scrollEl.addEventListener('resize', this.scrollListener); + + if (this.props.initialLoad) { + this.scrollListener(); + } + } + + detachScrollListener() { + var scrollEl = window; + if (this.props.useWindow === false) { + scrollEl = this.scrollComponent.parentNode; + } + + scrollEl.removeEventListener('scroll', this.scrollListener); + scrollEl.removeEventListener('resize', this.scrollListener); + } + + componentWillUnmount() { + this.detachScrollListener(); + } + + // Set a defaut loader for all your `InfiniteScroll` components + setDefaultLoader(loader) { + this.defaultLoader = loader; + } +} diff --git a/components/gif_picker/components/Search/index.jsx b/components/gif_picker/components/Search/index.jsx new file mode 100644 index 000000000000..f4f9b38be125 --- /dev/null +++ b/components/gif_picker/components/Search/index.jsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import {searchIfNeededInitial, searchGfycat} from 'mattermost-redux/actions/gifs'; + +import SearchGrid from 'components/gif_picker/components/SearchGrid'; + +function mapStateToProps(state) { + return { + ...state.entities.gifs.search, + }; +} + +const mapDispatchToProps = ({ + searchGfycat, + searchIfNeededInitial, +}); + +export class Search extends PureComponent { + static propTypes = { + handleItemClick: PropTypes.func, + onCategories: PropTypes.func, + searchText: PropTypes.string, + searchIfNeededInitial: PropTypes.func, + searchGfycat: PropTypes.func, + } + + componentDidMount() { + const {searchText} = this.props; + this.props.searchIfNeededInitial(searchText.split('-').join(' ')); + } + + componentDidUpdate(prevProps) { + const {searchText} = this.props; + if (prevProps.searchText !== searchText) { + this.props.searchIfNeededInitial(searchText.split('-').join(' ')); + } + } + + loadMore = () => { + const {searchText} = this.props; + this.props.searchGfycat({searchText}); + } + + render() { + const {handleItemClick, searchText, onCategories} = this.props; + + return ( + + ); + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/components/gif_picker/components/SearchBar/SearchBar.scss b/components/gif_picker/components/SearchBar/SearchBar.scss new file mode 100644 index 000000000000..57d45cb54e96 --- /dev/null +++ b/components/gif_picker/components/SearchBar/SearchBar.scss @@ -0,0 +1,73 @@ +.search-bar { + position: relative; + width: 100%; + padding: .8rem .6rem 0; + box-sizing: border-box; + + .search-input { + position: relative; + display: block; + box-sizing: border-box; + width: 100%; + height: 2.7rem; + padding: 0 2.6rem; + font-size: 1.3rem; + border-radius: 1.4rem; + border-width: 1px; + border-style: solid; + outline: none; + box-shadow: none; + z-index: 1; + background-color: transparent; + } + + // Hide IE input clear button + .search-input::-ms-clear { + display: none; + } + + .search-input-bg { + position: absolute; + width: calc(100% - 2.4rem); + height: 3.3rem; + border-radius: .6rem; + } + + .placeholder { + position: absolute; + top: 1.3rem; + left: 3.3rem; + font-size: 1.2rem; + } + + .ic { + position: absolute; + top: calc(50% - .3rem); + } + + .ic-clear-search, .ic-search { + background-repeat: no-repeat; + background-position: center; + cursor: pointer; + position: absolute; + z-index: 1; + } + + .ic-clear-search { + top: 1.1rem; + right: 1rem; + width: 2rem; + height: 2rem; + } + + .ic-search { + top: .7rem; + left: .7rem; + width: 2.5rem; + height: 3rem; + } +} + +.search-form .submit-button { + display: none; +} diff --git a/components/gif_picker/components/SearchBar/index.jsx b/components/gif_picker/components/SearchBar/index.jsx new file mode 100644 index 000000000000..0a0f0bf10d02 --- /dev/null +++ b/components/gif_picker/components/SearchBar/index.jsx @@ -0,0 +1,251 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import {saveSearchScrollPosition, saveSearchBarText, searchTextUpdate} from 'mattermost-redux/actions/gifs'; +import {changeOpacity, makeStyleFromTheme} from 'mattermost-redux/utils/theme_utils'; + +import Constants from 'utils/constants'; + +import store from 'stores/redux_store.jsx'; + +import GifSearchIcon from 'components/svg/gif_search_icon'; +import GifSearchClearIcon from 'components/svg/gif_search_clear_icon'; + +import './SearchBar.scss'; + +function mapStateToProps(state) { + return { + ...state.entities.gifs.categories, + ...state.entities.gifs.search, + appProps: state.entities.gifs.app, + }; +} + +const mapDispatchToProps = ({ + saveSearchBarText, + saveSearchScrollPosition, + searchTextUpdate, +}); + +const getStyle = makeStyleFromTheme((theme) => { + return { + background: { + backgroundColor: theme.centerChannelBg, + }, + icon: { + fill: changeOpacity(theme.centerChannelColor, 0.4), + }, + inputBackground: { + backgroundColor: theme.centerChannelBg, + }, + input: { + borderColor: changeOpacity(theme.centerChannelColor, 0.12), + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + placeholder: { + color: changeOpacity(theme.centerChannelColor, 0.6), + }, + }; +}); + +export class SearchBar extends Component { + static propTypes = { + searchBarText: PropTypes.string, + tagsList: PropTypes.array, + onTrending: PropTypes.func, + onSearch: PropTypes.func, + onCategories: PropTypes.func, + action: PropTypes.string, + saveSearchScrollPosition: PropTypes.func, + saveSearchBarText: PropTypes.func, + searchTextUpdate: PropTypes.func, + } + + constructor(props) { + super(props); + + this.state = { + inputFocused: false, + }; + + this.searchTimeout = null; + this.props.saveSearchBarText(''); + this.props.searchTextUpdate(''); + } + + componentDidUpdate(prevProps) { + const {searchBarText} = this.props; + + if (searchBarText !== prevProps.searchBarText) { + if (searchBarText === 'trending') { + this.updateSearchInputValue(''); + } else { + this.updateSearchInputValue(searchBarText); + } + } + } + + /** + * Returns text request with hyphens + */ + parseSearchText = (searchText) => { + return searchText.trim().split(/ +/).join('-'); + } + + removeExtraSpaces = (searchText) => { + return searchText.trim().split(/ +/).join(' '); + } + + updateSearchInputValue = (searchText) => { + this.searchInput.value = searchText; + this.props.saveSearchBarText(searchText); + } + + handleSubmit = (event) => { + event.preventDefault(); + this.triggerSearch(this.searchInput.value); + this.searchInput.blur(); + } + + triggerSearch = (searchText) => { + const {onSearch} = this.props; + this.props.searchTextUpdate(this.parseSearchText(searchText)); + onSearch(); + this.props.saveSearchScrollPosition(0); + } + + handleChange = (event) => { + clearTimeout(this.searchTimeout); + + const searchText = event.target.value; + + const {onCategories, action} = this.props; + this.props.saveSearchBarText(searchText); + + if (searchText === '') { + onCategories(); + } else if (action !== 'reactions' || !this.isFilteredTags(searchText)) { + // not reactions page or there's no reactions for this search request + this.searchTimeout = setTimeout(() => { + this.triggerSearch(searchText); + }, 500); + } + } + + focusInput = () => { + this.setState({inputFocused: true}); + } + + blurInput = () => { + this.setState({inputFocused: false}); + } + + /** + * Checks if there're reactions for a current searchText + */ + isFilteredTags = (searchText) => { + var text = this.removeExtraSpaces(searchText); + + const {tagsList} = this.props; + const substr = text.toLowerCase(); + const filteredTags = tagsList && tagsList.length ? tagsList.filter((tag) => { + if (!text || tag.tagName.indexOf(substr) !== -1) { + return tag; + } + return ''; + }) : []; + + return Boolean(filteredTags.length); + } + + clearSearchHandle = () => { + const {action, onTrending, onCategories} = this.props; + this.updateSearchInputValue(''); + if (action === 'reactions') { + onCategories(); + } else { + onTrending(); + } + } + + shouldComponentUpdate(nextProps, nextState) { + return ((!nextProps.searchBarText && this.props.searchBarText) || + (nextProps.searchBarText && !this.props.searchBarText) || + (nextState.inputFocused !== this.state.inputFocused)); + } + + render() { + const prefs = store.getState().entities.preferences.myPreferences; + const theme = 'theme--' in prefs ? JSON.parse(prefs['theme--'].value) : Constants.THEMES.default; + const style = getStyle(theme); + const {searchBarText} = this.props; + const clearSearchButton = searchBarText ? + ( + + ) : null; + + const placeholder = !searchBarText && !this.state.inputFocused ? + ( + {'Search Gfycat'} + ) : + null; + + return ( +
+
+
+ {placeholder} + { + this.searchInput = input; + return input; + }} + style={style.input} + /> + + {clearSearchButton} +
+