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 (
+
+ );
+ }
+}
+
+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 (
+
+
+
+ );
+ }) : [];
+
+ 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 (
+
+ );
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchBar);
+
diff --git a/components/gif_picker/components/SearchGrid/SearchGrid.jsx b/components/gif_picker/components/SearchGrid/SearchGrid.jsx
new file mode 100644
index 000000000000..7485752a7058
--- /dev/null
+++ b/components/gif_picker/components/SearchGrid/SearchGrid.jsx
@@ -0,0 +1,244 @@
+// 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 {saveSearchScrollPosition} from 'mattermost-redux/actions/gifs';
+import {changeOpacity, makeStyleFromTheme} from 'mattermost-redux/utils/theme_utils';
+
+import store from 'stores/redux_store.jsx';
+
+import {trackEvent} from 'actions/diagnostics_actions.jsx';
+
+import InfiniteScroll from 'components/gif_picker/components/InfiniteScroll';
+import SearchItem from 'components/gif_picker/components/SearchItem';
+
+import Constants from 'utils/constants';
+import './SearchGrid.scss';
+
+const ITEMS_PADDING = 8;
+const NUMBER_OF_COLUMNS_PORTRAIT = 2;
+const NUMBER_OF_COLUMNS_LANDSCAPE = 2;
+const WEBKIT_SCROLLBAR_WIDTH = 8;
+
+function mapStateToProps(state) {
+ return {
+ ...state.entities.gifs.cache,
+ ...state.entities.gifs.search,
+ appProps: state.entities.gifs.app,
+ };
+}
+
+const mapDispatchToProps = ({
+ saveSearchScrollPosition,
+});
+
+const getStyle = makeStyleFromTheme((theme) => {
+ return {
+ background: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.05),
+ },
+ };
+});
+
+export class SearchGrid extends PureComponent {
+ static propTypes = {
+ appProps: PropTypes.object,
+ gifs: PropTypes.object,
+ resultsByTerm: PropTypes.object,
+ containerClassName: PropTypes.string,
+ keyword: PropTypes.string, // searchText, tagName
+ handleItemClick: PropTypes.func,
+ onCategories: PropTypes.func,
+ loadMore: PropTypes.func,
+ numberOfColumns: PropTypes.number,
+ scrollPosition: PropTypes.number,
+ saveSearchScrollPosition: PropTypes.func,
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ containerWidth: null,
+ };
+ this.scrollPosition = this.props.scrollPosition;
+ this.setNumberOfColumns();
+
+ /**
+ * Inital values for columns heights
+ */
+ this.columnsHeights = Array(this.numberOfColumns).fill(0);
+
+ /**
+ * Items padding value
+ */
+ this.padding = ITEMS_PADDING;
+ }
+
+ componentDidMount() {
+ this.container = document.getElementById('search-grid-container');
+ // eslint-disable-next-line react/no-did-mount-set-state
+ this.setState({
+ ...this.state,
+ containerWidth: this.container.offsetWidth - WEBKIT_SCROLLBAR_WIDTH,
+ });
+ window.addEventListener('resize', this.resizeHandler);
+ window.addEventListener('scroll', this.scrollHandler);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.keyword !== this.props.keyword) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ componentWillUnmount() {
+ const {keyword} = this.props;
+ if (keyword !== 'trending') {
+ this.props.saveSearchScrollPosition(this.scrollPosition);
+ }
+
+ window.removeEventListener('resize', this.resizeHandler);
+ window.removeEventListener('scroll', this.scrollHandler);
+ }
+
+ setNumberOfColumns = () => {
+ if (window.matchMedia('(orientation: portrait)').matches) {
+ this.numberOfColumns = NUMBER_OF_COLUMNS_PORTRAIT;
+ } else {
+ this.numberOfColumns = NUMBER_OF_COLUMNS_LANDSCAPE;
+ }
+ }
+
+ itemClickHandler = (gfyItem) => {
+ const {keyword, handleItemClick} = this.props;
+ this.props.saveSearchScrollPosition(this.scrollPosition);
+
+ trackEvent('gfycat', 'shares', {gfyid: gfyItem.gfyId, keyword});
+ handleItemClick(gfyItem);
+ }
+
+ minHeightColumnIndex = () => {
+ return this.columnsHeights.indexOf(Math.min(...this.columnsHeights));
+ }
+
+ maxHeightColumnIndex = () => {
+ return this.columnsHeights.indexOf(Math.max(...this.columnsHeights));
+ }
+
+ maxColumnHeight = () => {
+ return Math.max(...this.columnsHeights);
+ }
+
+ resizeHandler = () => {
+ if (this.state.containerWidth !== this.container.offsetWidth - WEBKIT_SCROLLBAR_WIDTH) {
+ this.setNumberOfColumns();
+ this.setState({
+ ...this.state,
+ containerWidth: this.container.offsetWidth - WEBKIT_SCROLLBAR_WIDTH,
+ });
+ this.columnsHeights = Array(this.numberOfColumns).fill(0);
+ }
+ }
+
+ scrollHandler = () => {
+ this.scrollPosition = window.scrollY;
+ }
+
+ 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 {
+ containerClassName,
+ gifs,
+ keyword,
+ resultsByTerm,
+ scrollPosition,
+ loadMore,
+ onCategories,
+ } = this.props;
+
+ const {containerWidth} = this.state;
+ const {moreRemaining, items = [], isEmpty} = resultsByTerm[keyword] ? resultsByTerm[keyword] : {};
+
+ /**
+ * Columns 'left' values
+ */
+ const columnWidth = parseInt(containerWidth / this.numberOfColumns, 10);
+ const leftPosition = Array(this.numberOfColumns).fill(0).map((item, index) => this.padding + ((index * columnWidth) - (index * (this.padding / 2))));
+
+ this.columnsHeights = Array(this.numberOfColumns).fill(this.padding);
+
+ // Item width in %
+ //const itemWidth = this.numberOfColumns === NUMBER_OF_COLUMNS_PORTRAIT ? 100 / NUMBER_OF_COLUMNS_PORTRAIT : 100 / this.numberOfColumns;
+ const itemWidth = 122;
+
+ const searchItems = containerWidth && items.length ?
+ items.map((item, index) => {
+ const gfyItem = gifs[item];
+ const {gfyId} = gfyItem;
+
+ // Position calculation
+ const colIndex = this.minHeightColumnIndex();
+ const top = this.columnsHeights[colIndex] + 'px';
+ const left = leftPosition[colIndex] + 'px';
+ const itemHeight = ((itemWidth / gfyItem.width) * gfyItem.height) + this.padding;
+ this.columnsHeights[colIndex] += itemHeight;
+
+ return (
+
+ );
+ }) : null;
+
+ this.containerHeight = this.maxColumnHeight();
+
+ const content = searchItems ? (
+
+ {searchItems}
+
+ ) : null;
+
+ const emptySearch = isEmpty ? (
+
+ ) : null;
+
+ return (
+
+ {content}
+ {emptySearch}
+
+ );
+ }
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchGrid);
diff --git a/components/gif_picker/components/SearchGrid/SearchGrid.scss b/components/gif_picker/components/SearchGrid/SearchGrid.scss
new file mode 100644
index 000000000000..1283dcf6b460
--- /dev/null
+++ b/components/gif_picker/components/SearchGrid/SearchGrid.scss
@@ -0,0 +1,36 @@
+.search-grid-container {
+ position: relative;
+ width: 100%;
+ margin: 0 auto;
+ font-size: 0;
+ overflow-x: hidden;
+ height: 292px;
+}
+
+.empty-search {
+ margin: 2rem auto;
+ font-size: 1.6rem;
+ color: #a0a0a0;
+ text-align: center;
+ a {
+ text-decoration: none;
+ }
+ .empty-search-button {
+ display: inline-block;
+ padding: .6rem 1.6rem;
+ font-size: 1.2rem;
+ line-height: 1.5;
+ color: #ffffff;
+ border-radius: .6rem;
+ background-color: #1b70ff;
+ }
+}
+.empty-search-image {
+ width: 100%;
+ height: 9rem;
+ margin-bottom: 2rem;
+ background-image: url('../../static/search_end.png');
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+}
diff --git a/components/gif_picker/components/SearchGrid/index.js b/components/gif_picker/components/SearchGrid/index.js
new file mode 100644
index 000000000000..5d457487384c
--- /dev/null
+++ b/components/gif_picker/components/SearchGrid/index.js
@@ -0,0 +1,24 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {connect} from 'react-redux';
+
+import {saveSearchScrollPosition} from 'mattermost-redux/actions/gifs';
+
+import SearchGrid from './SearchGrid';
+
+function mapStateToProps(state) {
+ return {
+ ...state.entities.gifs.cache,
+ ...state.entities.gifs.search,
+ appProps: state.entities.gifs.app,
+ };
+}
+
+function mapDispatchToProps() {
+ return {
+ saveSearchScrollPosition,
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchGrid);
diff --git a/components/gif_picker/components/SearchItem/SearchItem.scss b/components/gif_picker/components/SearchItem/SearchItem.scss
new file mode 100644
index 000000000000..2f472b75e635
--- /dev/null
+++ b/components/gif_picker/components/SearchItem/SearchItem.scss
@@ -0,0 +1,14 @@
+.search-item-wrapper {
+ width: 50%;
+ position: absolute;
+ border-radius: .6rem;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+.search-item {
+ width: 100%;
+ display: inline-block;
+ background-size: cover;
+ border-radius: .6rem;
+ cursor: pointer;
+}
diff --git a/components/gif_picker/components/SearchItem/index.jsx b/components/gif_picker/components/SearchItem/index.jsx
new file mode 100644
index 000000000000..740a5e1b6e3a
--- /dev/null
+++ b/components/gif_picker/components/SearchItem/index.jsx
@@ -0,0 +1,60 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {PureComponent} from 'react';
+import {connect} from 'react-redux';
+
+import PropTypes from 'prop-types';
+
+import './SearchItem.scss';
+import * as PostUtils from 'utils/post_utils.jsx';
+
+function mapStateToProps(state) {
+ return {
+ hasImageProxy: state.entities.general.config.HasImageProxy,
+ };
+}
+
+export class SearchItem extends PureComponent {
+ static propTypes = {
+ gfyItem: PropTypes.object,
+ top: PropTypes.string,
+ left: PropTypes.string,
+ itemWidth: PropTypes.number,
+ itemClickHandler: PropTypes.func,
+ hasImageProxy: PropTypes.string,
+ }
+
+ render() {
+ const {
+ gfyItem,
+ top,
+ left,
+ itemWidth,
+ itemClickHandler,
+ } = this.props;
+
+ const {width, height, max1mbGif, avgColor} = gfyItem;
+ const {hasImageProxy} = this.props;
+ const url = PostUtils.getImageSrc(max1mbGif, hasImageProxy === 'true');
+
+ const backgroundImage = {backgroundImage: `url(${url})`};
+ const backgroundColor = {backgroundColor: avgColor};
+ const paddingBottom = {paddingBottom: ((itemWidth / width) * height) + 'px'};
+
+ return (
+
+
itemClickHandler(gfyItem)}
+ />
+
+ );
+ }
+}
+
+export default connect(mapStateToProps)(SearchItem);
diff --git a/components/gif_picker/components/Trending/index.jsx b/components/gif_picker/components/Trending/index.jsx
new file mode 100644
index 000000000000..8a8cfa5da007
--- /dev/null
+++ b/components/gif_picker/components/Trending/index.jsx
@@ -0,0 +1,56 @@
+// 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 {
+ searchCategory,
+ searchIfNeededInitial,
+ saveSearchScrollPosition,
+} from 'mattermost-redux/actions/gifs';
+
+import SearchGrid from 'components/gif_picker/components/SearchGrid';
+
+const mapDispatchToProps = ({
+ searchCategory,
+ searchIfNeededInitial,
+ saveSearchScrollPosition,
+});
+
+export class Trending extends PureComponent {
+ static propTypes = {
+ handleItemClick: PropTypes.func,
+ onCategories: PropTypes.func,
+ searchCategory: PropTypes.func,
+ searchIfNeededInitial: PropTypes.func,
+ saveSearchScrollPosition: PropTypes.func,
+ }
+
+ componentDidMount() {
+ this.props.searchIfNeededInitial('trending');
+ }
+
+ componentWillUnmount() {
+ this.props.saveSearchScrollPosition(0);
+ }
+
+ loadMore = () => {
+ this.props.searchCategory({tagName: 'trending'});
+ }
+
+ render() {
+ const {handleItemClick, onCategories} = this.props;
+ return (
+
+ );
+ }
+}
+
+export default connect(null, mapDispatchToProps)(Trending);
diff --git a/components/gif_picker/gif_picker.jsx b/components/gif_picker/gif_picker.jsx
new file mode 100644
index 000000000000..c129dae736ee
--- /dev/null
+++ b/components/gif_picker/gif_picker.jsx
@@ -0,0 +1,127 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+import App from 'components/gif_picker/components/App';
+import Categories from 'components/gif_picker/components/Categories';
+import Search from 'components/gif_picker/components/Search';
+import Trending from 'components/gif_picker/components/Trending';
+import constants from 'components/gif_picker/utils/constants';
+
+export const appProps = {
+ appName: constants.appName.mattermost,
+ basePath: '/mattermost',
+ itemTapType: constants.ItemTapAction.SHARE,
+ appClassName: 'gfycat',
+ shareEvent: 'shareMattermost',
+ appId: 'mattermostwebviews',
+ enableHistory: true,
+ header: {
+ tabs: [constants.Tab.TRENDING, constants.Tab.REACTIONS],
+ displayText: false,
+ },
+};
+
+export default class GifPicker extends React.Component {
+ static propTypes = {
+ style: PropTypes.object,
+ rightOffset: PropTypes.number,
+ topOffset: PropTypes.number,
+ placement: PropTypes.oneOf(['top', 'bottom', 'left']),
+ onGifClick: PropTypes.func.isRequired,
+ }
+
+ static defaultProps = {
+ rightOffset: 0,
+ topOffset: 0,
+ };
+
+ constructor(props) {
+ super(props);
+
+ // All props are primitives or treated as immutable
+ this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
+
+ this.handleTrending = this.handleTrending.bind(this);
+ this.handleCategories = this.handleCategories.bind(this);
+ this.handleSearch = this.handleSearch.bind(this);
+ this.handleItemClick = this.handleItemClick.bind(this);
+
+ this.state = {
+ action: 'trending',
+ };
+ }
+
+ handleTrending() {
+ this.setState({
+ action: 'trending',
+ });
+ }
+
+ handleCategories() {
+ this.setState({
+ action: 'reactions',
+ });
+ }
+
+ handleSearch() {
+ this.setState({
+ action: 'search',
+ });
+ }
+
+ handleItemClick(gif) {
+ this.props.onGifClick(gif.max5mbGif);
+ }
+
+ render() {
+ const {action} = this.state;
+ let component;
+ switch (action) {
+ case 'reactions':
+ component = (
+
+ );
+ break;
+ case 'search':
+ component = (
+
+ );
+ break;
+ case 'trending':
+ component = (
+
+ );
+ break;
+ }
+ return (
+
+ );
+ }
+}
diff --git a/components/gif_picker/static/favicons/favicon-16x16.png b/components/gif_picker/static/favicons/favicon-16x16.png
new file mode 100644
index 000000000000..c5bea61566f9
Binary files /dev/null and b/components/gif_picker/static/favicons/favicon-16x16.png differ
diff --git a/components/gif_picker/static/favicons/favicon-32x32.png b/components/gif_picker/static/favicons/favicon-32x32.png
new file mode 100644
index 000000000000..0343395f6123
Binary files /dev/null and b/components/gif_picker/static/favicons/favicon-32x32.png differ
diff --git a/components/gif_picker/static/favicons/favicon-96x96.png b/components/gif_picker/static/favicons/favicon-96x96.png
new file mode 100644
index 000000000000..fc9d4dc19303
Binary files /dev/null and b/components/gif_picker/static/favicons/favicon-96x96.png differ
diff --git a/components/gif_picker/static/favicons/favicon.ico b/components/gif_picker/static/favicons/favicon.ico
new file mode 100644
index 000000000000..f16fc63899cc
Binary files /dev/null and b/components/gif_picker/static/favicons/favicon.ico differ
diff --git a/components/gif_picker/static/gif.worker.js b/components/gif_picker/static/gif.worker.js
new file mode 100644
index 000000000000..3c20d2ab96e3
--- /dev/null
+++ b/components/gif_picker/static/gif.worker.js
@@ -0,0 +1,4 @@
+/* eslint-disable */
+// gif.worker.js 0.2.0 - https://github.com/jnordberg/gif.js
+(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o
=ByteArray.pageSize)this.newPage();this.pages[this.page][this.cursor++]=val};ByteArray.prototype.writeUTFBytes=function(string){for(var l=string.length,i=0;i=0)this.dispose=disposalCode};GIFEncoder.prototype.setRepeat=function(repeat){this.repeat=repeat};GIFEncoder.prototype.setTransparent=function(color){this.transparent=color};GIFEncoder.prototype.addFrame=function(imageData){this.image=imageData;this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null;this.getImagePixels();this.analyzePixels();if(this.globalPalette===true)this.globalPalette=this.colorTab;if(this.firstFrame){this.writeLSD();this.writePalette();if(this.repeat>=0){this.writeNetscapeExt()}}this.writeGraphicCtrlExt();this.writeImageDesc();if(!this.firstFrame&&!this.globalPalette)this.writePalette();this.writePixels();this.firstFrame=false};GIFEncoder.prototype.finish=function(){this.out.writeByte(59)};GIFEncoder.prototype.setQuality=function(quality){if(quality<1)quality=1;this.sample=quality};GIFEncoder.prototype.setDither=function(dither){if(dither===true)dither="FloydSteinberg";this.dither=dither};GIFEncoder.prototype.setGlobalPalette=function(palette){this.globalPalette=palette};GIFEncoder.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette};GIFEncoder.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")};GIFEncoder.prototype.analyzePixels=function(){if(!this.colorTab){this.neuQuant=new NeuQuant(this.pixels,this.sample);this.neuQuant.buildColormap();this.colorTab=this.neuQuant.getColormap()}if(this.dither){this.ditherPixels(this.dither.replace("-serpentine",""),this.dither.match(/-serpentine/)!==null)}else{this.indexPixels()}this.pixels=null;this.colorDepth=8;this.palSize=7;if(this.transparent!==null){this.transIndex=this.findClosest(this.transparent,true)}};GIFEncoder.prototype.indexPixels=function(imgq){var nPix=this.pixels.length/3;this.indexedPixels=new Uint8Array(nPix);var k=0;for(var j=0;j=0&&x1+x=0&&y1+y>16,(c&65280)>>8,c&255,used)};GIFEncoder.prototype.findClosestRGB=function(r,g,b,used){if(this.colorTab===null)return-1;if(this.neuQuant&&!used){return this.neuQuant.lookupRGB(r,g,b)}var c=b|g<<8|r<<16;var minpos=0;var dmin=256*256*256;var len=this.colorTab.length;for(var i=0,index=0;i=0){disp=dispose&7}disp<<=2;this.out.writeByte(0|disp|0|transp);this.writeShort(this.delay);this.out.writeByte(this.transIndex);this.out.writeByte(0)};GIFEncoder.prototype.writeImageDesc=function(){this.out.writeByte(44);this.writeShort(0);this.writeShort(0);this.writeShort(this.width);this.writeShort(this.height);if(this.firstFrame||this.globalPalette){this.out.writeByte(0)}else{this.out.writeByte(128|0|0|0|this.palSize)}};GIFEncoder.prototype.writeLSD=function(){this.writeShort(this.width);this.writeShort(this.height);this.out.writeByte(128|112|0|this.palSize);this.out.writeByte(0);this.out.writeByte(0)};GIFEncoder.prototype.writeNetscapeExt=function(){this.out.writeByte(33);this.out.writeByte(255);this.out.writeByte(11);this.out.writeUTFBytes("NETSCAPE2.0");this.out.writeByte(3);this.out.writeByte(1);this.writeShort(this.repeat);this.out.writeByte(0)};GIFEncoder.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);var n=3*256-this.colorTab.length;for(var i=0;i>8&255)};GIFEncoder.prototype.writePixels=function(){var enc=new LZWEncoder(this.width,this.height,this.indexedPixels,this.colorDepth);enc.encode(this.out)};GIFEncoder.prototype.stream=function(){return this.out};module.exports=GIFEncoder},{"./LZWEncoder.js":2,"./TypedNeuQuant.js":3}],2:[function(require,module,exports){var EOF=-1;var BITS=12;var HSIZE=5003;var masks=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function LZWEncoder(width,height,pixels,colorDepth){var initCodeSize=Math.max(2,colorDepth);var accum=new Uint8Array(256);var htab=new Int32Array(HSIZE);var codetab=new Int32Array(HSIZE);var cur_accum,cur_bits=0;var a_count;var free_ent=0;var maxcode;var clear_flg=false;var g_init_bits,ClearCode,EOFCode;function char_out(c,outs){accum[a_count++]=c;if(a_count>=254)flush_char(outs)}function cl_block(outs){cl_hash(HSIZE);free_ent=ClearCode+2;clear_flg=true;output(ClearCode,outs)}function cl_hash(hsize){for(var i=0;i=0){disp=hsize_reg-i;if(i===0)disp=1;do{if((i-=disp)<0)i+=hsize_reg;if(htab[i]===fcode){ent=codetab[i];continue outer_loop}}while(htab[i]>=0)}output(ent,outs);ent=c;if(free_ent<1<0){outs.writeByte(a_count);outs.writeBytes(accum,0,a_count);a_count=0}}function MAXCODE(n_bits){return(1<0)cur_accum|=code<=8){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}if(free_ent>maxcode||clear_flg){if(clear_flg){maxcode=MAXCODE(n_bits=g_init_bits);clear_flg=false}else{++n_bits;if(n_bits==BITS)maxcode=1<0){char_out(cur_accum&255,outs);cur_accum>>=8;cur_bits-=8}flush_char(outs)}}this.encode=encode}module.exports=LZWEncoder},{}],3:[function(require,module,exports){var ncycles=100;var netsize=256;var maxnetpos=netsize-1;var netbiasshift=4;var intbiasshift=16;var intbias=1<>betashift;var betagamma=intbias<>3;var radiusbiasshift=6;var radiusbias=1<>3);var i,v;for(i=0;i>=netbiasshift;network[i][1]>>=netbiasshift;network[i][2]>>=netbiasshift;network[i][3]=i}}function altersingle(alpha,i,b,g,r){network[i][0]-=alpha*(network[i][0]-b)/initalpha;network[i][1]-=alpha*(network[i][1]-g)/initalpha;network[i][2]-=alpha*(network[i][2]-r)/initalpha}function alterneigh(radius,i,b,g,r){var lo=Math.abs(i-radius);var hi=Math.min(i+radius,netsize);var j=i+1;var k=i-1;var m=1;var p,a;while(jlo){a=radpower[m++];if(jlo){p=network[k--];p[0]-=a*(p[0]-b)/alpharadbias;p[1]-=a*(p[1]-g)/alpharadbias;p[2]-=a*(p[2]-r)/alpharadbias}}}function contest(b,g,r){var bestd=~(1<<31);var bestbiasd=bestd;var bestpos=-1;var bestbiaspos=bestpos;var i,n,dist,biasdist,betafreq;for(i=0;i>intbiasshift-netbiasshift);if(biasdist>betashift;freq[i]-=betafreq;bias[i]+=betafreq<>1;for(j=previouscol+1;j>1;for(j=previouscol+1;j<256;j++)netindex[j]=maxnetpos}function inxsearch(b,g,r){var a,p,dist;var bestd=1e3;var best=-1;var i=netindex[g];var j=i-1;while(i=0){if(i=bestd)i=netsize;else{i++;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist=0){p=network[j];dist=g-p[1];if(dist>=bestd)j=-1;else{j--;if(dist<0)dist=-dist;a=p[0]-b;if(a<0)a=-a;dist+=a;if(dist>radiusbiasshift;if(rad<=1)rad=0;for(i=0;i=lengthcount)pix-=lengthcount;i++;if(delta===0)delta=1;if(i%delta===0){alpha-=alpha/alphadec;radius-=radius/radiusdec;rad=radius>>radiusbiasshift;if(rad<=1)rad=0;for(j=0;j
+
+
diff --git a/components/gif_picker/static/icons/ic_canvas_border.svg b/components/gif_picker/static/icons/ic_canvas_border.svg
new file mode 100644
index 000000000000..affaf8edb5ec
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_canvas_border.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_clear_search.svg b/components/gif_picker/static/icons/ic_clear_search.svg
new file mode 100644
index 000000000000..22dd38cf5bc4
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_clear_search.svg
@@ -0,0 +1,5 @@
+
diff --git a/components/gif_picker/static/icons/ic_clear_search_filled.svg b/components/gif_picker/static/icons/ic_clear_search_filled.svg
new file mode 100644
index 000000000000..2428d23ea1b7
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_clear_search_filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/components/gif_picker/static/icons/ic_close.svg b/components/gif_picker/static/icons/ic_close.svg
new file mode 100644
index 000000000000..0122c7d9bd5e
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_close.svg
@@ -0,0 +1,5 @@
+
diff --git a/components/gif_picker/static/icons/ic_drawing_blue.svg b/components/gif_picker/static/icons/ic_drawing_blue.svg
new file mode 100644
index 000000000000..6c16c905c2c4
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_drawing_blue.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_drawing_grey.svg b/components/gif_picker/static/icons/ic_drawing_grey.svg
new file mode 100644
index 000000000000..42cd13ffd85a
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_drawing_grey.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_pencil.svg b/components/gif_picker/static/icons/ic_pencil.svg
new file mode 100644
index 000000000000..f06100f0a41e
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_pencil.svg
@@ -0,0 +1,3 @@
+
diff --git a/components/gif_picker/static/icons/ic_reactions_blue.svg b/components/gif_picker/static/icons/ic_reactions_blue.svg
new file mode 100644
index 000000000000..6aeb2da7d601
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_reactions_blue.svg
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_reactions_grey.svg b/components/gif_picker/static/icons/ic_reactions_grey.svg
new file mode 100644
index 000000000000..00855a88770f
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_reactions_grey.svg
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_reactions_white.svg b/components/gif_picker/static/icons/ic_reactions_white.svg
new file mode 100644
index 000000000000..8364bade6130
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_reactions_white.svg
@@ -0,0 +1,9 @@
+
+
+
diff --git a/components/gif_picker/static/icons/ic_search_grey.svg b/components/gif_picker/static/icons/ic_search_grey.svg
new file mode 100644
index 000000000000..0a68026d2e02
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_search_grey.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/components/gif_picker/static/icons/ic_send_white.svg b/components/gif_picker/static/icons/ic_send_white.svg
new file mode 100644
index 000000000000..dca4d157c550
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_send_white.svg
@@ -0,0 +1,6 @@
+
diff --git a/components/gif_picker/static/icons/ic_skype_send.svg b/components/gif_picker/static/icons/ic_skype_send.svg
new file mode 100644
index 000000000000..c8ffb5490fe9
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_skype_send.svg
@@ -0,0 +1,7 @@
+
+
+
diff --git a/components/gif_picker/static/icons/ic_trash_can.svg b/components/gif_picker/static/icons/ic_trash_can.svg
new file mode 100644
index 000000000000..4165daa1e1d9
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_trash_can.svg
@@ -0,0 +1,6 @@
+
diff --git a/components/gif_picker/static/icons/ic_trending_blue.svg b/components/gif_picker/static/icons/ic_trending_blue.svg
new file mode 100644
index 000000000000..4d4b602ffd62
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_trending_blue.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_trending_grey.svg b/components/gif_picker/static/icons/ic_trending_grey.svg
new file mode 100644
index 000000000000..5a2f3a6c570b
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_trending_grey.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/components/gif_picker/static/icons/ic_trending_white.svg b/components/gif_picker/static/icons/ic_trending_white.svg
new file mode 100644
index 000000000000..ef5773246887
--- /dev/null
+++ b/components/gif_picker/static/icons/ic_trending_white.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/components/gif_picker/static/img_loading.png b/components/gif_picker/static/img_loading.png
new file mode 100644
index 000000000000..f859b69224dd
Binary files /dev/null and b/components/gif_picker/static/img_loading.png differ
diff --git a/components/gif_picker/static/search_end.png b/components/gif_picker/static/search_end.png
new file mode 100755
index 000000000000..86e531edee51
Binary files /dev/null and b/components/gif_picker/static/search_end.png differ
diff --git a/components/gif_picker/utils/constants.js b/components/gif_picker/utils/constants.js
new file mode 100644
index 000000000000..8dd296da82ad
--- /dev/null
+++ b/components/gif_picker/utils/constants.js
@@ -0,0 +1,18 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export default {
+ ItemTapAction: {
+ OPEN_EMBED_PAGE: 1,
+ SHARE: 2,
+ },
+
+ Tab: {
+ TRENDING: 0,
+ REACTIONS: 1,
+ },
+
+ appName: {
+ mattermost: 'mattermost',
+ },
+};
diff --git a/components/gif_picker/utils/request.js b/components/gif_picker/utils/request.js
new file mode 100644
index 000000000000..b549b432cc87
--- /dev/null
+++ b/components/gif_picker/utils/request.js
@@ -0,0 +1,65 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// This is a modified version of the code found in the gfycat-sdk project.
+// Supported values for options: timeout, method, url, file, payload
+export default function(options) {
+ return new Promise((resolve, reject) => {
+ var timeout = options.timeout || 30000;
+
+ var timer = setTimeout(() => {
+ xhr.abort();
+ reject(new Error('API request exceeded timeout of', timeout));
+ }, timeout);
+
+ var xhr = new XMLHttpRequest();
+
+ function handleError(err) {
+ clearTimeout(timer);
+ reject(err || new Error('API request failed'));
+ }
+
+ function handleResponse() {
+ clearTimeout(timer);
+
+ if (xhr.status >= 400) {
+ return reject(xhr.status);
+ }
+
+ var body = xhr.response;
+ try {
+ body = JSON.parse(body);
+ resolve(body);
+ } catch (e) {
+ resolve({});
+ }
+ return null;
+ }
+
+ xhr.addEventListener('error', handleError);
+ xhr.addEventListener('abort', handleError);
+ xhr.addEventListener('load', handleResponse);
+
+ xhr.open(options.method, options.url, true);
+
+ var headers = options.headers || null;
+ if (headers) {
+ Object.keys(headers).forEach((header) => {
+ xhr.setRequestHeader(header, headers[header]);
+ });
+ }
+
+ if (options.file) {
+ xhr.send(options.file);
+ } else {
+ var data = JSON.stringify(options.payload) || null;
+
+ if (data) {
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.send(data);
+ } else {
+ xhr.send();
+ }
+ }
+ });
+}
diff --git a/components/svg/gfycat_icon.jsx b/components/svg/gfycat_icon.jsx
new file mode 100644
index 000000000000..301f73da56ec
--- /dev/null
+++ b/components/svg/gfycat_icon.jsx
@@ -0,0 +1,38 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+export default class GfycatIcon extends React.PureComponent {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/components/svg/gif_reactions_icon.jsx b/components/svg/gif_reactions_icon.jsx
new file mode 100644
index 000000000000..b2518da33feb
--- /dev/null
+++ b/components/svg/gif_reactions_icon.jsx
@@ -0,0 +1,52 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+export default class GifReactionsIcon extends React.PureComponent {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/components/svg/gif_search_clear_icon.jsx b/components/svg/gif_search_clear_icon.jsx
new file mode 100644
index 000000000000..ab1f08d8b8c8
--- /dev/null
+++ b/components/svg/gif_search_clear_icon.jsx
@@ -0,0 +1,27 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+export default class GifSearchClearIcon extends React.PureComponent {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/components/svg/gif_search_icon.jsx b/components/svg/gif_search_icon.jsx
new file mode 100644
index 000000000000..e0ac02f9596a
--- /dev/null
+++ b/components/svg/gif_search_icon.jsx
@@ -0,0 +1,29 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+export default class GifSearchIcon extends React.PureComponent {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/components/svg/gif_trending_icon.jsx b/components/svg/gif_trending_icon.jsx
new file mode 100644
index 000000000000..0b33cff42b4a
--- /dev/null
+++ b/components/svg/gif_trending_icon.jsx
@@ -0,0 +1,49 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+export default class GifTrendingIcon extends React.PureComponent {
+ render() {
+ return (
+
+
+
+ );
+ }
+}
diff --git a/i18n/en.json b/i18n/en.json
index d0df07efa576..0d0013d38738 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -303,8 +303,15 @@
"admin.customization.enableCustomEmojiTitle": "Enable Custom Emoji:",
"admin.customization.enableEmojiPickerDesc": "The emoji picker allows users to select emoji to add as reactions or use in messages. Enabling the emoji picker with a large number of custom emoji may slow down performance.",
"admin.customization.enableEmojiPickerTitle": "Enable Emoji Picker:",
+ "admin.customization.enableGifPickerDesc": "Allow users to select GIFs from the emoji picker via a Gfycat integration.",
+ "admin.customization.enableGifPickerTitle": "Enable GIF Picker:",
"admin.customization.enableLinkPreviewsDesc": "Display a preview of website content below messages, when available. Users can disable these previews from Account Settings > Display > Website Link Previews.",
"admin.customization.enableLinkPreviewsTitle": "Enable Link Previews:",
+ "admin.customization.gfycatApiKey": "Gfycat API Key:",
+ "admin.customization.gfycatApiKeyDescription": "Request an API key at https://developers.gfycat.com/signup/#/. Enter the client ID you receive via email to this field.",
+ "admin.customization.gfycatApiSecret": "Gfycat API Secret:",
+ "admin.customization.gfycatApiSecretDescription": "The API secret generated by Gfycat for your API key.",
+ "admin.customization.gif": "GIF (Beta)",
"admin.customization.iosAppDownloadLinkDesc": "Add a link to download the iOS app. Users who access the site on a mobile web browser will be prompted with a page giving them the option to download the app. Leave this field blank to prevent the page from appearing.",
"admin.customization.iosAppDownloadLinkTitle": "iOS App Download Link:",
"admin.customization.nativeAppLinks": "Mattermost App Links",
@@ -1817,7 +1824,7 @@
"emoji_picker.people": "People",
"emoji_picker.places": "Places",
"emoji_picker.recent": "Recently Used",
- "emoji_picker.search": "Search",
+ "emoji_picker.search": "Search Emoji",
"emoji_picker.searchResults": "Search Results",
"emoji_picker.symbols": "Symbols",
"ent.cluster.save_config_with_roles.error": "The following configuration settings cannot be saved when High Availability is enabled and the System Console is in read-only mode: {keys}.",
diff --git a/package.json b/package.json
index 3cb7cef294f9..cd98bc1f4325 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
"localforage": "1.7.2",
"localforage-observable": "1.4.0",
"marked": "github:mattermost/marked#ed33baecd7d7fa97d479ba22dde9d226b083d67d",
- "mattermost-redux": "github:mattermost/mattermost-redux#6aee7c45bcde7d2079ee5b24f70e7e8d071ae0a6",
+ "mattermost-redux": "github:mattermost/mattermost-redux#c3a7215403d8607a21a30e11f6066308c448ce75",
"moment-timezone": "0.5.20",
"pdfjs-dist": "2.0.489",
"perfect-scrollbar": "0.8.1",
diff --git a/sass/components/_emoticons.scss b/sass/components/_emoticons.scss
index 35a2db09e27e..c591016dfe88 100644
--- a/sass/components/_emoticons.scss
+++ b/sass/components/_emoticons.scss
@@ -66,11 +66,54 @@
&.bottom {
margin-top: 10px;
}
+
+ .nav-tabs {
+ margin-bottom: 0px;
+
+ li {
+ width: 139px;
+
+ a {
+ border: 0;
+ border-bottom-width: 2px;
+ border-bottom-style: solid;
+ opacity: 0.7;
+ outline: none;
+ height: 34px;
+ margin-left: 5px;
+ margin-right: 5px;
+ padding-top: 9px;
+ }
+ }
+
+ li.active {
+ width: 139px;
+
+ a {
+ border: 0 !important;
+ border-bottom-width: 2px !important;
+ border-bottom-style: solid !important;
+ border-radius: 1px;
+ opacity: 1;
+ height: 34px;
+ margin-left: 5px;
+ margin-right: 5px;
+ padding-top: 9px;
+ }
+ }
+
+ svg {
+ top: 2px;
+ }
+ }
}
.emoji-picker__categories {
flex-grow: 0;
flex-shrink: 0;
+ height: 34px;
+ padding-left: 3px;
+ padding-top: 3px;
.emoji-picker__category {
color: #333333;
@@ -93,11 +136,13 @@
.emoji-picker__search-container {
position: relative;
- border-top-style: solid;
- border-top-width: 1px;
+ border-style: solid;
+ border-width: 1px;
+ border-radius: 14px;
+ margin: 6px 5px 0px 5px;
.emoji-picker__search-icon {
- padding-left: 6px;
+ padding-left: 7px;
padding-top: 6px;
position: absolute;
font-size: 13px;
@@ -105,7 +150,8 @@
.emoji-picker__search {
border-width: 0px;
- padding: 2px 0 2px 25px;
+ border-radius: inherit;
+ padding: 2px 0 2px 26px;
width: 100%;
height: 25px;
font-size: 12px;
@@ -223,14 +269,11 @@
flex-grow: 0;
flex-shrink: 0;
height: 50px;
- justify-content: flex-start;
- padding: 7px 10px 0;
- position: relative;
- vertical-align: middle;
+ padding: 5px 10px;
width: 278px;
&.emoji-picker__preview-placeholder {
- padding: 12px;
+ padding: 15px;
}
> div {
@@ -254,6 +297,7 @@
}
.emoji-picker__preview-image-box {
+ margin-top: 2px;
display: flex;
align-items: center;
justify-content: center;
diff --git a/tests/components/__snapshots__/edit_post_modal.test.jsx.snap b/tests/components/__snapshots__/edit_post_modal.test.jsx.snap
index 81fe47634b6c..6bea4c94538c 100644
--- a/tests/components/__snapshots__/edit_post_modal.test.jsx.snap
+++ b/tests/components/__snapshots__/edit_post_modal.test.jsx.snap
@@ -79,7 +79,9 @@ exports[`components/EditPostModal should match with default config 1`] = `
>
{
resetCreatePostRequest: jest.fn(),
readOnlyChannel: false,
enableEmojiPicker: true,
+ enableGifPicker: true,
enableConfirmNotificationsToChannel: true,
maxPostSize: Constants.DEFAULT_CHARACTER_LIMIT,
};
diff --git a/tests/components/create_post/__snapshots__/create_post.test.jsx.snap b/tests/components/create_post/__snapshots__/create_post.test.jsx.snap
index edda7dc6b263..c5ca5b09613a 100644
--- a/tests/components/create_post/__snapshots__/create_post.test.jsx.snap
+++ b/tests/components/create_post/__snapshots__/create_post.test.jsx.snap
@@ -49,7 +49,9 @@ exports[`components/create_post Show tutorial 1`] = `
className="emoji-picker__container"
>
diff --git a/utils/utils.jsx b/utils/utils.jsx
index 6c6a28844db8..5fdb9958f2b3 100644
--- a/utils/utils.jsx
+++ b/utils/utils.jsx
@@ -765,7 +765,8 @@ export function applyTheme(theme) {
changeCss('.app__body .post-reaction:not(.post-reaction--current-user)', 'color:' + changeOpacity(theme.centerChannelColor, 0.7));
changeCss('.app__body .emoji-picker', 'color:' + theme.centerChannelColor);
changeCss('.app__body .emoji-picker', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2));
- changeCss('.app__body .emoji-picker__preview, .app__body .emoji-picker__items, .app__body .emoji-picker__search-container', 'border-top-color:' + changeOpacity(theme.centerChannelColor, 0.2));
+ changeCss('.app__body .emoji-picker__search-icon', 'color:' + changeOpacity(theme.centerChannelColor, 0.4));
+ changeCss('.app__body .emoji-picker__preview, .app__body .emoji-picker__items, .app__body .emoji-picker__search-container', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.2));
changeCss('.app__body .emoji-picker__items', 'background-color:' + changeOpacity(theme.centerChannelColor, 0.05));
changeCss('.emoji-picker__category .fa:hover', 'color:' + changeOpacity(theme.centerChannelColor, 0.8));
changeCss('.app__body .emoji-picker__category, .app__body .emoji-picker__category:focus, .app__body .emoji-picker__category:hover', 'color:' + changeOpacity(theme.centerChannelColor, 0.3));
@@ -774,7 +775,7 @@ export function applyTheme(theme) {
changeCss('.app__body .emoji-picker-items__container .emoji-picker__item.selected', 'background-color:' + changeOpacity(theme.centerChannelColor, 0.8));
changeCss('.app__body .icon__postcontent_picker:hover', 'color:' + changeOpacity(theme.centerChannelColor, 0.8));
changeCss('.app__body .popover', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.07));
-
+ changeCss('.app__body .emoji-picker .nav-tabs li a', 'fill:' + theme.centerChannelColor);
changeCss('.app__body .post .post-collapse__show-more-button', `border-color:${changeOpacity(theme.centerChannelColor, 0.1)}`);
changeCss('.app__body .post .post-collapse__show-more-line', `background-color:${changeOpacity(theme.centerChannelColor, 0.1)}`);
@@ -926,6 +927,8 @@ export function applyTheme(theme) {
changeCss('.app__body .system-notice__logo svg', 'fill:' + theme.buttonBg);
changeCss('.app__body .post-image__details .post-image__download svg:hover', 'border-color:' + theme.buttonBg);
changeCss('.app__body .btn.btn-primary:hover, .app__body .btn.btn-primary:active, .app__body .btn.btn-primary:focus', 'background:' + changeColor(theme.buttonBg, -0.15));
+ changeCss('.app__body .emoji-picker .nav-tabs li.active a, .app__body .emoji-picker .nav-tabs li a:hover', 'fill:' + theme.buttonBg);
+ changeCss('.app__body .emoji-picker .nav-tabs > li.active > a', 'border-bottom-color:' + theme.buttonBg + '!important;');
}
if (theme.buttonColor) {
@@ -972,11 +975,12 @@ export function changeCss(className, classValue) {
const styleSheet = styleEl.sheet;
const rules = styleSheet.cssRules || styleSheet.rules;
const style = classValue.substr(0, classValue.indexOf(':'));
- const value = classValue.substr(classValue.indexOf(':') + 1);
+ const value = classValue.substr(classValue.indexOf(':') + 1).replace(/!important[;]/g, '');
+ const priority = (classValue.match(/!important/) ? 'important' : null);
for (let i = 0; i < rules.length; i++) {
if (rules[i].selectorText === className) {
- rules[i].style[style] = value;
+ rules[i].style.setProperty(style, value, priority);
return;
}
}