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' + /> +
Tenor logo +
+
+ ); + } + +} + + +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 @@ + + + + TENOR_VECTOR + Created with Sketch. + + + + + + + + + + + + + + + + + \ 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"