diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 9e0b123704..283928a0c0 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -70,6 +70,7 @@ export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET';
+export const COMPOSE_TENOR_SET = 'COMPOSE_TENOR_SET';
export const COMPOSE_POLL_ADD = 'COMPOSE_POLL_ADD';
export const COMPOSE_POLL_REMOVE = 'COMPOSE_POLL_REMOVE';
@@ -297,7 +298,14 @@ export function doodleSet(options) {
};
}
-export function uploadCompose(files) {
+export function tenorSet(options) {
+ return {
+ type: COMPOSE_TENOR_SET,
+ options: options,
+ };
+}
+
+export function uploadCompose(files, alt = '') {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
@@ -323,6 +331,7 @@ export function uploadCompose(files) {
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
+ data.append('description', alt);
// Account for disparity in size of original image and resized data
total += file.size - f.size;
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.jsx b/app/javascript/flavours/glitch/features/compose/components/options.jsx
index 98bba41198..f03da41bed 100644
--- a/app/javascript/flavours/glitch/features/compose/components/options.jsx
+++ b/app/javascript/flavours/glitch/features/compose/components/options.jsx
@@ -137,6 +137,9 @@ class ComposerOptions extends ImmutablePureComponent {
onChangeContentType: PropTypes.func,
onTogglePoll: PropTypes.func,
onDoodleOpen: PropTypes.func,
+ onEmbedTenor: PropTypes.func,
+ onModalClose: PropTypes.func,
+ onModalOpen: PropTypes.func,
onToggleSpoiler: PropTypes.func,
onUpload: PropTypes.func,
contentType: PropTypes.string,
@@ -157,7 +160,7 @@ class ComposerOptions extends ImmutablePureComponent {
// Handles attachment clicks.
handleClickAttach = (name) => {
const { fileElement } = this;
- const { onDoodleOpen } = this.props;
+ const { onDoodleOpen, onEmbedTenor } = this.props;
// We switch over the name of the option.
switch (name) {
@@ -171,6 +174,11 @@ class ComposerOptions extends ImmutablePureComponent {
onDoodleOpen();
}
return;
+ case 'gif':
+ if (onEmbedTenor) {
+ onEmbedTenor();
+ }
+ return;
}
};
@@ -252,6 +260,11 @@ class ComposerOptions extends ImmutablePureComponent {
name: 'doodle',
text: formatMessage(messages.doodle),
},
+ {
+ icon: 'file-image-o',
+ name: 'gif',
+ text: formatMessage(messages.gif),
+ }
]}
onChange={this.handleClickAttach}
title={formatMessage(messages.attach)}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
index c44d3ce305..94e5fe20e2 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/options_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -6,7 +6,7 @@ import {
addPoll,
removePoll,
} from 'flavours/glitch/actions/compose';
-import { openModal } from 'flavours/glitch/actions/modal';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import Options from '../components/options';
@@ -51,6 +51,24 @@ const mapDispatchToProps = (dispatch) => ({
modalProps: { noEsc: true, noClose: true },
}));
},
+
+ onEmbedTenor() {
+ dispatch(openModal({
+ modalType: 'TENOR',
+ modalProps: { noEsc: true },
+ }));
+ },
+
+ onModalClose() {
+ dispatch(closeModal({
+ modalType: undefined,
+ ignoreFocus: false,
+ }));
+ },
+
+ onModalOpen(props) {
+ dispatch(openModal({ modalType: 'ACTIONS', modalProps: props }));
+ },
});
export default connect(mapStateToProps, mapDispatchToProps)(Options);
diff --git a/app/javascript/flavours/glitch/features/ui/components/gif_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/gif_modal.jsx
new file mode 100644
index 0000000000..0ab2800d90
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/gif_modal.jsx
@@ -0,0 +1,108 @@
+import PropTypes from 'prop-types';
+
+import { defineMessages, injectIntl } from 'react-intl';
+
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+
+import Tenor from 'react-tenor';
+
+import { tenorSet, uploadCompose } from 'flavours/glitch/actions/compose';
+import { IconButton } from 'flavours/glitch/components/icon_button';
+
+const messages = defineMessages({
+ search: { id: 'tenor.search', defaultMessage: 'Search for GIFs' },
+ error: { id: 'tenor.error', defaultMessage: 'Oops! Something went wrong. Please, try again.' },
+ loading: { id: 'tenor.loading', defaultMessage: 'Loading...' },
+ nomatches: { id: 'tenor.nomatches', defaultMessage: 'No matches found.' },
+ close: { id: 'settings.close', defaultMessage: 'Close' },
+});
+
+// Utility for converting base64 image to binary for upload
+// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
+function dataURLtoFile(dataurl, filename) {
+ let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
+ bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+ while(n--){
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ return new File([u8arr], filename, { type: mime });
+}
+
+const mapStateToProps = state => ({
+ options: state.getIn(['compose', 'tenor']),
+});
+
+const mapDispatchToProps = dispatch => ({
+ /**
+ * Set options in the redux store
+ * @param opts
+ */
+ setOpt: (opts) => dispatch(tenorSet(opts)),
+ /**
+ * Submit GIF for upload
+ * @param file
+ * @param alt
+ */
+ submit: (file, alt) => dispatch(uploadCompose([file], alt)),
+});
+
+class GIFModal extends ImmutablePureComponent {
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ options: ImmutablePropTypes.map,
+ onClose: PropTypes.func.isRequired,
+ setOpt: PropTypes.func.isRequired,
+ submit: PropTypes.func.isRequired,
+ };
+
+ onDoneButton = (result) => {
+ const url = result.media[0].mp4.url;
+ const alt = result.content_description;
+ var modal = this;
+ // eslint-disable-next-line promise/catch-or-return
+ fetch(url).then(function(response) {
+ return response.blob();
+ }).then(function(blob) {
+ const reader = new FileReader();
+ reader.readAsDataURL(blob);
+ reader.onloadend = function() {
+ var dataUrl = reader.result;
+ const file = dataURLtoFile(dataUrl, 'tenor.mp4');
+ modal.props.submit(file, alt);
+ modal.props.onClose(); // close dialog
+ };
+ });
+ };
+
+ render () {
+
+ const { intl } = this.props;
+
+ return (
+
+
+
+
this.onDoneButton(result)}
+ autoFocus='true'
+ searchPlaceholder={intl.formatMessage(messages.search)}
+ messageError={intl.formatMessage(messages.error)}
+ messageLoading={intl.formatMessage(messages.loading)}
+ messageNoMatches={intl.formatMessage(messages.nomatches)}
+ contentFilter='off'
+ />
+
+
+
+ );
+ }
+
+}
+
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(GIFModal));
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
index 78a7ac2297..1226f54de3 100644
--- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx
@@ -33,6 +33,7 @@ import DeprecatedSettingsModal from './deprecated_settings_modal';
import DoodleModal from './doodle_modal';
import FavouriteModal from './favourite_modal';
import FocalPointModal from './focal_point_modal';
+import GIFModal from './gif_modal';
import ImageModal from './image_modal';
import MediaModal from './media_modal';
import ModalLoading from './modal_loading';
@@ -47,6 +48,7 @@ export const MODAL_COMPONENTS = {
'BOOST': () => Promise.resolve({ default: BoostModal }),
'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }),
'DOODLE': () => Promise.resolve({ default: DoodleModal }),
+ 'TENOR': () => Promise.resolve({ default: GIFModal }),
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'MUTE': MuteModal,
'BLOCK': BlockModal,
@@ -94,7 +96,7 @@ export default class ModalRoot extends PureComponent {
};
renderLoading = modalId => () => {
- return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
+ return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', 'DOODLE', 'TENOR', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null;
};
renderError = (props) => {
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 0915ecba0f..66aad890fe 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -40,6 +40,7 @@ import {
COMPOSE_UPLOAD_CHANGE_SUCCESS,
COMPOSE_UPLOAD_CHANGE_FAIL,
COMPOSE_DOODLE_SET,
+ COMPOSE_TENOR_SET,
COMPOSE_RESET,
COMPOSE_POLL_ADD,
COMPOSE_POLL_REMOVE,
@@ -108,6 +109,7 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
tagHistory: ImmutableList(),
+ tenor: null,
media_modal: ImmutableMap({
id: null,
description: '',
@@ -567,6 +569,8 @@ export default function compose(state = initialState, action) {
}));
case COMPOSE_DOODLE_SET:
return state.mergeIn(['doodle'], action.options);
+ case COMPOSE_TENOR_SET:
+ return state.mergeIn(['tenor'], action.options);
case REDRAFT:
const do_not_federate = !!action.status.get('local_only');
let text = action.raw_text || unescapeHTML(expandMentions(action.status));
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index d94f123648..eb9c6c4278 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -11,6 +11,7 @@
@import 'search';
@import 'emoji';
@import 'doodle';
+@import 'tenor';
@import 'drawer';
@import 'media';
@import 'sensitive';
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index d0db308a15..e7ce1da576 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -414,6 +414,7 @@
}
.doodle-modal,
+.tenor-modal,
.boost-modal,
.confirmation-modal,
.report-modal,
diff --git a/app/javascript/flavours/glitch/styles/components/tenor.scss b/app/javascript/flavours/glitch/styles/components/tenor.scss
new file mode 100644
index 0000000000..d9e1292757
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/tenor.scss
@@ -0,0 +1,120 @@
+.tenor-modal__container {
+ text-align: center;
+ padding: 20px;
+}
+
+.tenor-modal__container .icon-button {
+ margin: 10px;
+}
+
+.react-tenor-active {
+ box-shadow: 0 0 5px 1px rgba(0, 0, 0, 20%);
+}
+
+.react-tenor--search {
+ box-sizing: border-box;
+ color: #555;
+ font-size: 1.5em;
+ margin-bottom: 10px;
+ line-height: 1.3;
+ overflow: visible;
+ padding: 10px;
+ width: 100%;
+ -webkit-appearance: none;
+}
+
+.react-tenor--search[type='search']::-webkit-search-cancel-button,
+.react-tenor--search[type='search']::-webkit-search-decoration {
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.react-tenor--search:focus {
+ box-shadow: 0 0 2px 2px #6a89af;
+ outline: none;
+}
+
+.react-tenor--autocomplete {
+ display: none;
+}
+
+.react-tenor--autocomplete span {
+ visibility: hidden;
+}
+
+.react-tenor--spinner {
+ display: none;
+}
+
+.react-tenor--suggestions {
+ display: none;
+}
+
+.react-tenor--results {
+ display: flex;
+ flex-wrap: wrap;
+ position: relative;
+}
+
+.react-tenor--result {
+ background: rgba(87, 87, 131, 15%);
+ border: 0;
+ cursor: pointer;
+ display: inline-block;
+ flex-basis: 25%;
+ height: 120px;
+ opacity: 1;
+ padding: 0;
+ transition: opacity 0.3s;
+ width: 120px;
+}
+
+.react-tenor--result span {
+ animation: react-tenor-fade-in 0.2s;
+ background-size: cover;
+ display: block;
+ height: 100%;
+ width: 100%;
+}
+
+.react-tenor--result:focus {
+ box-shadow: 0 0 2px 2px #6a89af;
+ border: 1px solid #f7f7f7;
+ outline: none;
+ z-index: 1;
+}
+
+.react-tenor--result:hover {
+ opacity: 0.5;
+}
+
+@media screen and (width <= 480px) {
+ .react-tenor--result {
+ flex-basis: 33%;
+ }
+}
+
+.react-tenor--page-left,
+.react-tenor--page-right {
+ display: none;
+}
+
+@keyframes react-tenor-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes react-tenor-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/package.json b/package.json
index 40578504e8..fbd87fd2f6 100644
--- a/package.json
+++ b/package.json
@@ -121,6 +121,7 @@
"react-select": "^5.7.3",
"react-sparklines": "^1.7.0",
"react-swipeable-views": "^0.14.0",
+ "react-tenor": "^2.2.0",
"react-textarea-autosize": "^8.4.1",
"react-toggle": "^4.1.3",
"redux": "^4.2.1",
diff --git a/public/tenor.svg b/public/tenor.svg
new file mode 100644
index 0000000000..bed2e8bc73
--- /dev/null
+++ b/public/tenor.svg
@@ -0,0 +1,22 @@
+
+
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 1f23f26dde..3d70b770cd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10418,6 +10418,11 @@ react-swipeable-views@^0.14.0:
react-swipeable-views-utils "^0.14.0"
warning "^4.0.1"
+react-tenor@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/react-tenor/-/react-tenor-2.2.0.tgz#98326868cc199165bacb911abdbbb48306232b6f"
+ integrity sha512-hs0KomduflTLU05fvKAtrf9f/aAj3wN2kPF2cl5cAOFPkhuaVJuWLNxRSAQN1m+aKHinujEQZ1fXp+gL1KgYhg==
+
react-test-renderer@^18.2.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-18.2.0.tgz#1dd912bd908ff26da5b9fca4fd1c489b9523d37e"