From ac70e6955e20fedeaee5f93f35542ba92c1ee6cb Mon Sep 17 00:00:00 2001 From: Essem Date: Sun, 5 Nov 2023 20:56:45 -0600 Subject: [PATCH] Add Tenor GIF picker Co-authored-by: koyu --- .../flavours/glitch/actions/compose.js | 11 +- .../compose/components/upload_button.jsx | 15 ++- .../containers/upload_button_container.js | 20 ++- .../features/ui/components/gif_modal.jsx | 109 ++++++++++++++++ .../features/ui/components/modal_root.jsx | 4 +- .../flavours/glitch/locales/en.json | 7 +- .../flavours/glitch/reducers/compose.js | 4 + .../flavours/glitch/styles/components.scss | 122 ++++++++++++++++++ .../material-icons/400-24px/gif_box-fill.svg | 1 + .../material-icons/400-24px/gif_box.svg | 1 + app/lib/content_security_policy.rb | 2 +- .../initializers/content_security_policy.rb | 4 +- package.json | 1 + public/tenor.svg | 22 ++++ yarn.lock | 11 ++ 15 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/ui/components/gif_modal.jsx create mode 100644 app/javascript/material-icons/400-24px/gif_box-fill.svg create mode 100644 app/javascript/material-icons/400-24px/gif_box.svg create mode 100644 public/tenor.svg diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 06f0d79874..50b73c1908 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -69,6 +69,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'; @@ -305,7 +306,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']); @@ -332,6 +340,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/upload_button.jsx b/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx index ed2cbb04f2..5f3073d822 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/upload_button.jsx @@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; +import GifBoxIcon from '@/material-icons/400-24px/gif_box.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-20px/photo_library.svg?react'; import BrushIcon from '@/material-icons/400-24px/brush.svg?react'; import UploadFileIcon from '@/material-icons/400-24px/upload_file.svg?react'; @@ -15,6 +16,7 @@ import { DropdownIconButton } from './dropdown_icon_button'; const messages = defineMessages({ upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' }, doodle: { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, + gif: { id: 'compose.attach.gif', defaultMessage: 'Upload GIF' }, }); const makeMapStateToProps = () => { @@ -31,6 +33,9 @@ class UploadButton extends ImmutablePureComponent { disabled: PropTypes.bool, onSelectFile: PropTypes.func.isRequired, onDoodleOpen: PropTypes.func.isRequired, + onEmbedTenor: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onModalOpen: PropTypes.func.isRequired, style: PropTypes.object, resetFileKey: PropTypes.number, acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, @@ -46,8 +51,10 @@ class UploadButton extends ImmutablePureComponent { handleSelect = (value) => { if (value === 'upload') { this.fileElement.click(); - } else { + } else if (value === 'doodle') { this.props.onDoodleOpen(); + } else if (value === 'gif') { + this.props.onEmbedTenor(); } }; @@ -73,6 +80,12 @@ class UploadButton extends ImmutablePureComponent { value: 'doodle', text: intl.formatMessage(messages.doodle), }, + { + icon: 'gif-box', + iconComponent: GifBoxIcon, + value: 'gif', + text: intl.formatMessage(messages.gif), + }, ]; return ( diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js index 2082510f12..7243e007ea 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { uploadCompose } from '../../../actions/compose'; -import { openModal } from '../../../actions/modal'; +import { openModal, closeModal } from '../../../actions/modal'; import UploadButton from '../components/upload_button'; const mapStateToProps = state => ({ @@ -21,6 +21,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)(UploadButton); 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..247e4c1493 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/gif_modal.jsx @@ -0,0 +1,109 @@ +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 CloseIcon from '@/material-icons/400-24px/close.svg?react'; +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 {Object} opts + */ + setOpt: (opts) => dispatch(tenorSet(opts)), + /** + * Submit GIF for upload + * @param {File} file + * @param {string} 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 4ecf07b030..92991e49a7 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -32,6 +32,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'; @@ -45,6 +46,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, @@ -92,7 +94,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/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index 5f64fffdcc..91150a520a 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -15,6 +15,7 @@ "column_subheading.navigation": "Navigation", "community.column_settings.allow_local_only": "Show local-only toots", "compose.attach.doodle": "Draw something", + "compose.attach.gif": "Upload GIF", "compose.change_federation": "Change federation settings", "compose.content-type.change": "Change advanced formatting options", "compose.content-type.html": "HTML", @@ -154,5 +155,9 @@ "status.is_poll": "This toot is a poll", "status.local_only": "Only visible from your instance", "status.uncollapse": "Uncollapse", - "suggestions.dismiss": "Dismiss suggestion" + "suggestions.dismiss": "Dismiss suggestion", + "tenor.error": "Oops! Something went wrong. Please, try again.", + "tenor.loading": "Loading...", + "tenor.nomatches": "No matches found.", + "tenor.search": "Search for GIFs" } diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 787e7ee2db..3f1c1a0855 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, @@ -100,6 +101,7 @@ const initialState = ImmutableMap({ resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, tagHistory: ImmutableList(), + tenor: null, media_modal: ImmutableMap({ id: null, description: '', @@ -573,6 +575,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.scss b/app/javascript/flavours/glitch/styles/components.scss index e7a0156a41..40a1592d25 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -6192,6 +6192,7 @@ a.status-card { } .doodle-modal, +.tenor-modal, .boost-modal, .confirmation-modal, .report-modal, @@ -10213,3 +10214,124 @@ noscript { } } } + +.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/app/javascript/material-icons/400-24px/gif_box-fill.svg b/app/javascript/material-icons/400-24px/gif_box-fill.svg new file mode 100644 index 0000000000..75afe08160 --- /dev/null +++ b/app/javascript/material-icons/400-24px/gif_box-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/gif_box.svg b/app/javascript/material-icons/400-24px/gif_box.svg new file mode 100644 index 0000000000..0fd79e550f --- /dev/null +++ b/app/javascript/material-icons/400-24px/gif_box.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/lib/content_security_policy.rb b/app/lib/content_security_policy.rb index 9dbcb35636..3ef8f00d62 100644 --- a/app/lib/content_security_policy.rb +++ b/app/lib/content_security_policy.rb @@ -10,7 +10,7 @@ class ContentSecurityPolicy end def media_hosts - [assets_host, cdn_host_value, paperclip_root_url].concat(extra_data_hosts).compact + [assets_host, cdn_host_value, paperclip_root_url, 'https://media.tenor.com'].concat(extra_data_hosts).compact end private diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index e43e38786c..e00eedbc7f 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -54,10 +54,10 @@ Rails.application.config.content_security_policy do |p| webpacker_public_host = ENV.fetch('WEBPACKER_DEV_SERVER_PUBLIC', Webpacker.config.dev_server[:public]) front_end_build_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{webpacker_public_host}" } - p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url, *front_end_build_urls + p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url, 'https://api.tenor.com', *front_end_build_urls p.script_src :self, :unsafe_inline, :unsafe_eval, assets_host else - p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url + p.connect_src :self, :data, :blob, *media_hosts, Rails.configuration.x.streaming_api_base_url, 'https://api.tenor.com' p.script_src :self, assets_host, "'wasm-unsafe-eval'" end end diff --git a/package.json b/package.json index e7428324a5..1a9fb8b94e 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,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-immutable": "^4.0.0", 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 9baca397ea..dd5fa65bbc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2446,6 +2446,7 @@ __metadata: react-select: "npm:^5.7.3" react-sparklines: "npm:^1.7.0" react-swipeable-views: "npm:^0.14.0" + react-tenor: "npm:^2.2.0" react-test-renderer: "npm:^18.2.0" react-textarea-autosize: "npm:^8.4.1" react-toggle: "npm:^4.1.3" @@ -13807,6 +13808,16 @@ __metadata: languageName: node linkType: hard +"react-tenor@npm:^2.2.0": + version: 2.2.0 + resolution: "react-tenor@npm:2.2.0" + peerDependencies: + react: ^16 + react-dom: ^16 + checksum: d6c46a7b4666916b4d22bc46ffe7667384d2958620cb843a820c8c24243e775b4a0e4b5146a19ec676a43f24236fddbea5fbe92ca14559794d1d1cd752a13587 + languageName: node + linkType: hard + "react-test-renderer@npm:^18.2.0": version: 18.2.0 resolution: "react-test-renderer@npm:18.2.0"