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'
+ />
+
+
+
+ );
+ }
+
+}
+
+
+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 @@
+
+
\ 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"