blob: 0c953e2496150aba443b34026afd952ce8ff655f [file] [log] [blame] [raw]
// Copyright (c) 2021, Compiler Explorer Authors
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// * Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above copyright
// notice, this list of conditions and the following disclaimer in the
// documentation and/or other materials provided with the distribution.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
'use strict';
var $ = require('jquery');
var _ = require('underscore');
var options = require('./options');
var url = require('./url');
var ga = require('./analytics');
var Sentry = require('@sentry/browser');
var cloneDeep = require('lodash.clonedeep');
var shareServices = {
twitter: {
embedValid: false,
logoClass: 'fab fa-twitter',
cssClass: 'share-twitter',
getLink: function (title, url) {
return 'https://twitter.com/intent/tweet?text=' +
encodeURIComponent(title) + '&url=' + encodeURIComponent(url) + '&via=CompileExplore';
},
text: 'Tweet',
},
reddit: {
embedValid: false,
logoClass: 'fab fa-reddit',
cssClass: 'share-reddit',
getLink: function (title, url) {
return 'http://www.reddit.com/submit?url=' +
encodeURIComponent(url) + '&title=' + encodeURIComponent(title);
},
text: 'Share on Reddit',
},
};
function filterComponentState(config, keysToRemove) {
keysToRemove = keysToRemove || ['selection'];
function filterComponentStateImpl(component) {
if (component.content) {
for (var i = 0; i < component.content.length; i++) {
filterComponentStateImpl(component.content[i], keysToRemove);
}
}
if (component.componentState) {
Object.keys(component.componentState)
.filter(function (key) {
return keysToRemove.includes(key);
})
.forEach(function (key) {
delete component.componentState[key];
});
}
}
config = cloneDeep(config);
filterComponentStateImpl(config);
return config;
}
function updateShares(container, url) {
var baseTemplate = $('#share-item');
_.each(shareServices, function (service, serviceName) {
var newElement = baseTemplate.children('a.share-item').clone();
if (service.logoClass) {
newElement.prepend($('<span>')
.addClass('dropdown-icon')
.addClass(service.logoClass)
.prop('title', serviceName)
);
}
if (service.text) {
newElement.children('span.share-item-text')
.text(service.text);
}
newElement
.prop('href', service.getLink('Compiler Explorer', url))
.addClass(service.cssClass)
.toggleClass('share-no-embeddable', !service.embedValid)
.appendTo(container);
});
}
function getEmbeddedUrl(config, root, readOnly, extraOptions) {
var location = window.location.origin + root;
var path = '';
var parameters = '';
_.forEach(extraOptions, function (value, key) {
if (parameters === '') {
parameters = '?';
} else {
parameters += '&';
}
parameters += key + '=' + value;
});
if (readOnly) {
path = 'embed-ro' + parameters + '#';
} else {
path = 'e' + parameters + '#';
}
return location + path + url.serialiseState(config);
}
function getEmbeddedHtml(config, root, isReadOnly, extraOptions) {
return '<iframe width="800px" height="200px" src="' +
getEmbeddedUrl(config, root, isReadOnly, extraOptions) + '"></iframe>';
}
function getShortLink(config, root, done) {
var useExternalShortener = options.urlShortenService !== 'default';
var data = JSON.stringify({
config: useExternalShortener ? url.serialiseState(config) : config,
});
$.ajax({
type: 'POST',
url: window.location.origin + root + 'api/shortener',
dataType: 'json', // Expected
contentType: 'application/json', // Sent
data: data,
success: _.bind(function (result) {
var pushState = useExternalShortener ? null : result.url;
done(null, result.url, pushState, true);
}, this),
error: _.bind(function (err) {
// Notify the user that we ran into trouble?
done(err.statusText, null, false);
}, this),
cache: true,
});
}
function getLinks(config, currentBind, done) {
var root = window.httpRoot;
ga.proxy('send', {
hitType: 'event',
eventCategory: 'CreateShareLink',
eventAction: 'Sharing',
});
switch (currentBind) {
case 'Short':
getShortLink(config, root, done);
return;
case 'Full':
done(null, window.location.origin + root + '#' + url.serialiseState(config), false);
return;
default:
if (currentBind.substr(0, 5) === 'Embed') {
var options = {};
$('#sharelinkdialog input:checked').each(function () {
options[$(this).prop('class')] = true;
});
done(null, getEmbeddedHtml(config, root, false, options), false);
return;
}
// Hmmm
done('Unknown link type', null);
}
}
function Sharing(layout) {
this.layout = layout;
this.lastState = null;
this.share = $('#share');
this.shareShort = $('#shareShort');
this.shareFull = $('#shareFull');
this.shareEmbed = $('#shareEmbed');
this.initButtons();
this.initCallbacks();
}
Sharing.prototype.initButtons = function () {
this.shareShortCopyToClipBtn = this.shareShort.find('.clip-icon');
this.shareFullCopyToClipBtn = this.shareFull.find('.clip-icon');
this.shareEmbedCopyToClipBtn = this.shareEmbed.find('.clip-icon');
if (this.areClipboardOperationSupported()) {
var onClipButtonPressed = _.bind(function (e, type) {
// Dont let the modal show up.
// We need this because the button is a child of the dropdown-item with a data-toggle=modal
e.stopPropagation();
this.copyLinkTypeToClipboard(type);
// As we prevented bubbling, the dropdown won't close by itself. We need to trigger it manually
this.share.dropdown('hide');
}, this);
this.shareShortCopyToClipBtn.on('click', _.bind(function (e) {
onClipButtonPressed(e, 'Short');
}, this));
this.shareFullCopyToClipBtn.on('click', _.bind(function (e) {
onClipButtonPressed(e, 'Full');
}, this));
this.shareEmbedCopyToClipBtn.on('click', _.bind(function (e) {
onClipButtonPressed(e, 'Embed');
}, this));
} else {
this.shareShortCopyToClipBtn.hide();
this.shareFullCopyToClipBtn.hide();
this.shareEmbedCopyToClipBtn.hide();
}
};
Sharing.prototype.onOpenModalPane = function (event) {
var button = $(event.relatedTarget);
var currentBind = button.data('bind');
var modal = $(event.currentTarget);
var socialSharingElements = modal.find('.socialsharing');
var permalink = modal.find('.permalink');
var embedsettings = modal.find('#embedsettings');
function updatePermaLink() {
socialSharingElements.empty();
var config = this.layout.toConfig();
getLinks(config, currentBind, _.bind(function (error, newUrl, extra, updateState) {
if (error || !newUrl) {
permalink.prop('disabled', true);
permalink.val(error || 'Error providing URL');
Sentry.captureException(error);
} else {
if (updateState) {
this.storeCurrentConfig(config, extra);
}
permalink.val(newUrl);
if (options.sharingEnabled) {
updateShares(socialSharingElements, newUrl);
// Disable the links for every share item which does not support embed html as links
if (currentBind === 'Embed') {
socialSharingElements.children('.share-no-embeddable')
.addClass('share-disabled')
.prop('title', 'Embed links are not supported in this service')
.on('click', false);
}
}
}
}, this));
}
if (currentBind === 'Embed') {
embedsettings.show();
embedsettings.find('input')
// Off any prev click handlers to avoid multiple events triggering after opening the modal more than once
.off('click')
.on('click', _.bind(function () {
updatePermaLink.apply(this);
}, this));
} else {
embedsettings.hide();
}
updatePermaLink.apply(this);
ga.proxy('send', {
hitType: 'event',
eventCategory: 'OpenModalPane',
eventAction: 'Sharing',
});
};
Sharing.prototype.onStateChanged = function () {
var config = filterComponentState(this.layout.toConfig());
this.ensureUrlIsNotOutdated(config);
if (options.embedded) {
var strippedToLast = window.location.pathname;
strippedToLast = strippedToLast.substr(0, strippedToLast.lastIndexOf('/') + 1);
$('a.link').attr('href', strippedToLast + '#' + url.serialiseState(config));
}
};
// This keeps the URL from displaying a short link of an outdated page state
Sharing.prototype.ensureUrlIsNotOutdated = function (config) {
var stringifiedConfig = JSON.stringify(config);
if (stringifiedConfig !== this.lastState) {
if (this.lastState != null && window.location.pathname !== window.httpRoot) {
window.history.replaceState(null, null, window.httpRoot);
}
this.lastState = stringifiedConfig;
}
};
Sharing.prototype.initCallbacks = function () {
this.layout.eventHub.on('displaySharingPopover', _.bind(function () {
this.shareShort.trigger('click');
}, this));
this.layout.on('stateChanged', _.bind(this.onStateChanged, this));
$('#sharelinkdialog').on('show.bs.modal', _.bind(this.onOpenModalPane, this));
};
Sharing.prototype.displayTooltip = function (message) {
this.share.tooltip('dispose');
this.share.tooltip({
placement: 'bottom',
trigger: 'manual',
title: message,
});
this.share.tooltip('show');
// Manual triggering of tooltips does not hide them automatically. This timeout ensures they do
setTimeout(_.bind(function () {
this.share.tooltip('hide');
}, this), 1500);
};
Sharing.prototype.copyLinkTypeToClipboard = function (type) {
var config = this.layout.toConfig();
getLinks(config, type, _.bind(function (error, newUrl, extra, updateState) {
if (error || !newUrl) {
this.displayTooltip('Oops, something went wrong');
Sentry.captureException(error);
} else {
if (updateState) {
this.storeCurrentConfig(config, extra);
}
this.doLinkCopyToClipboard(newUrl);
}
}, this));
};
Sharing.prototype.doLinkCopyToClipboard = function (link) {
// TODO: Add more ways for users to be able to copy the link text
// Right now, only the newer navigator.clipboard is available, but more can be added
if (this.isNavigatorClipboardAvailable()) {
navigator.clipboard.writeText(link)
.then(_.bind(function () {
this.displayTooltip('Link copied to clipboard');
}, this))
.catch(_.bind(function () {
this.displayTooltip('Error copying link to clipboard');
}, this));
}
};
Sharing.prototype.storeCurrentConfig = function (config, extra) {
window.history.pushState(null, null, extra);
};
// True if there's at least one way to copy a link to the user's clipboard.
// Currently, only navigator.clipboard is supported
Sharing.prototype.areClipboardOperationSupported = function () {
return this.isNavigatorClipboardAvailable();
};
Sharing.prototype.isNavigatorClipboardAvailable = function () {
return navigator.clipboard != null;
};
module.exports = {
Sharing: Sharing,
filterComponentState: filterComponentState,
};