|  | // 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. | 
|  |  | 
|  | import $ from 'jquery'; | 
|  | import Sentry from '@sentry/browser'; | 
|  | import GoldenLayout from 'golden-layout'; | 
|  | import _ from 'underscore'; | 
|  | import ClipboardJS from 'clipboard'; | 
|  |  | 
|  | import ClickEvent = JQuery.ClickEvent; | 
|  | import TriggeredEvent = JQuery.TriggeredEvent; | 
|  |  | 
|  | const ga = require('./analytics').ga; | 
|  | const options = require('./options').options; | 
|  | const url = require('./url'); | 
|  | const cloneDeep = require('lodash.clonedeep'); | 
|  |  | 
|  | enum LinkType { | 
|  | Short, | 
|  | Full, | 
|  | Embed | 
|  | } | 
|  |  | 
|  | const shareServices = { | 
|  | twitter: { | 
|  | embedValid: false, | 
|  | logoClass: 'fab fa-twitter', | 
|  | cssClass: 'share-twitter', | 
|  | getLink: (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: (title, url) => { | 
|  | return 'http://www.reddit.com/submit' + | 
|  | `?url=${encodeURIComponent(url)}` + | 
|  | `&title=${encodeURIComponent(title)}`; | 
|  | }, | 
|  | text: 'Share on Reddit', | 
|  | }, | 
|  | }; | 
|  |  | 
|  | export class Sharing { | 
|  | private layout: GoldenLayout; | 
|  | private lastState: any; | 
|  |  | 
|  | private share: JQuery; | 
|  | private shareShort: JQuery; | 
|  | private shareFull: JQuery; | 
|  | private shareEmbed: JQuery; | 
|  |  | 
|  | private clippyButton: ClipboardJS | null; | 
|  |  | 
|  | constructor(layout: any) { | 
|  | this.layout = layout; | 
|  | this.lastState = null; | 
|  |  | 
|  | this.share = $('#share'); | 
|  | this.shareShort = $('#shareShort'); | 
|  | this.shareFull = $('#shareFull'); | 
|  | this.shareEmbed = $('#shareEmbed'); | 
|  |  | 
|  | this.clippyButton = null; | 
|  |  | 
|  | this.initButtons(); | 
|  | this.initCallbacks(); | 
|  | } | 
|  |  | 
|  | private initCallbacks(): void { | 
|  | this.layout.eventHub.on('displaySharingPopover', () => { | 
|  | this.openShareModalForType(LinkType.Short); | 
|  | }); | 
|  | this.layout.eventHub.on('copyShortLinkToClip', () => { | 
|  | this.copyLinkTypeToClipboard(LinkType.Short); | 
|  | }); | 
|  | this.layout.on('stateChanged', this.onStateChanged.bind(this)); | 
|  |  | 
|  | $('#sharelinkdialog').on('show.bs.modal', this.onOpenModalPane.bind(this)) | 
|  | .on('hidden.bs.modal', this.onCloseModalPane.bind(this)); | 
|  | } | 
|  |  | 
|  | private onStateChanged(): void { | 
|  | const config = Sharing.filterComponentState(this.layout.toConfig()); | 
|  | this.ensureUrlIsNotOutdated(config); | 
|  | if (options.embedded) { | 
|  | const strippedToLast = window.location.pathname.substr(0, window.location.pathname.lastIndexOf('/') + 1); | 
|  | $('a.link').prop('href', strippedToLast + '#' + url.serialiseState(config)); | 
|  | } | 
|  | } | 
|  |  | 
|  | private ensureUrlIsNotOutdated(config: any): void { | 
|  | const stringifiedConfig = JSON.stringify(config); | 
|  | if (stringifiedConfig !== this.lastState) { | 
|  | if (this.lastState != null && window.location.pathname !== window.httpRoot) { | 
|  | window.history.replaceState(null, '', window.httpRoot); | 
|  | } | 
|  | this.lastState = stringifiedConfig; | 
|  | } | 
|  | } | 
|  |  | 
|  | private static bindToLinkType(bind: string): LinkType { | 
|  | switch (bind) { | 
|  | case 'Full': return LinkType.Full; | 
|  | case 'Short': return LinkType.Short; | 
|  | case 'Embed': return LinkType.Embed; | 
|  | default: return LinkType.Full; | 
|  | } | 
|  | } | 
|  |  | 
|  | private onOpenModalPane(event: TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>): void { | 
|  | // @ts-ignore The property is added by bootstrap | 
|  | const button = $(event.relatedTarget); | 
|  | const currentBind = Sharing.bindToLinkType(button.data('bind')); | 
|  | const modal = $(event.currentTarget); | 
|  | const socialSharingElements = modal.find('.socialsharing'); | 
|  | const permalink = modal.find('.permalink'); | 
|  | const embedsettings = modal.find('#embedsettings'); | 
|  |  | 
|  | const updatePermaLink = () => { | 
|  | socialSharingElements.empty(); | 
|  | const config = this.layout.toConfig(); | 
|  | Sharing.getLinks(config, currentBind, (error: any, newUrl: string, extra: string, updateState: boolean) => { | 
|  | permalink.off('click'); | 
|  | if (error || !newUrl) { | 
|  | permalink.prop('disabled', true); | 
|  | permalink.val(error || 'Error providing URL'); | 
|  | Sentry.captureException(error); | 
|  | } else { | 
|  | if (updateState) { | 
|  | Sharing.storeCurrentConfig(config, extra); | 
|  | } | 
|  | permalink.val(newUrl); | 
|  | permalink.on('click', () => { | 
|  | permalink.trigger('focus').trigger('select'); | 
|  | }); | 
|  | if (options.sharingEnabled) { | 
|  | Sharing.updateShares(socialSharingElements, newUrl); | 
|  | // Disable the links for every share item which does not support embed html as links | 
|  | if (currentBind === LinkType.Embed) { | 
|  | socialSharingElements.children('.share-no-embeddable') | 
|  | .hide() | 
|  | .on('click', false); | 
|  | } | 
|  | } | 
|  | } | 
|  | }); | 
|  | }; | 
|  |  | 
|  | const clippyElement = modal.find('button.clippy').get(0); | 
|  | if (clippyElement != null) { | 
|  | this.clippyButton = new ClipboardJS(clippyElement); | 
|  | this.clippyButton.on('success', (e) => { | 
|  | this.displayTooltip(permalink, 'Link copied to clipboard'); | 
|  | e.clearSelection(); | 
|  | }); | 
|  | this.clippyButton.on('error', (e) => { | 
|  | this.displayTooltip(permalink, 'Error copying to clipboard'); | 
|  | }); | 
|  | } | 
|  |  | 
|  | if (currentBind === LinkType.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', () => updatePermaLink()); | 
|  | } else { | 
|  | embedsettings.hide(); | 
|  | } | 
|  |  | 
|  | updatePermaLink(); | 
|  |  | 
|  | ga.proxy('send', { | 
|  | hitType: 'event', | 
|  | eventCategory: 'OpenModalPane', | 
|  | eventAction: 'Sharing', | 
|  | }); | 
|  | } | 
|  |  | 
|  | private onCloseModalPane(): void { | 
|  | if (this.clippyButton) { | 
|  | this.clippyButton.destroy(); | 
|  | this.clippyButton = null; | 
|  | } | 
|  | } | 
|  |  | 
|  | private initButtons(): void { | 
|  | const shareShortCopyToClipBtn = this.shareShort.find('.clip-icon'); | 
|  | const shareFullCopyToClipBtn = this.shareFull.find('.clip-icon'); | 
|  | const shareEmbedCopyToClipBtn = this.shareEmbed.find('.clip-icon'); | 
|  |  | 
|  | shareShortCopyToClipBtn.on('click', (e) => this.onClipButtonPressed(e, LinkType.Short)); | 
|  | shareFullCopyToClipBtn.on('click', (e) => this.onClipButtonPressed(e, LinkType.Full)); | 
|  | shareEmbedCopyToClipBtn.on('click', (e) => this.onClipButtonPressed(e, LinkType.Embed)); | 
|  |  | 
|  | if (options.sharingEnabled) { | 
|  | Sharing.updateShares($('#socialshare'), window.location.protocol + '//' + window.location.hostname); | 
|  | } | 
|  | } | 
|  |  | 
|  | private onClipButtonPressed(event: ClickEvent, type: LinkType): void { | 
|  | // Dont let the modal show up. | 
|  | // We need this because the button is a child of the dropdown-item with a data-toggle=modal | 
|  | if (Sharing.isNavigatorClipboardAvailable()) { | 
|  | event.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'); | 
|  | } | 
|  | } | 
|  |  | 
|  | private copyLinkTypeToClipboard(type: LinkType): void { | 
|  | const config = this.layout.toConfig(); | 
|  | Sharing.getLinks(config, type, (error: any, newUrl: string, extra: string, updateState: boolean) => { | 
|  | if (error || !newUrl) { | 
|  | this.displayTooltip(this.share, 'Oops, something went wrong'); | 
|  | Sentry.captureException(error); | 
|  | } else { | 
|  | if (updateState) { | 
|  | Sharing.storeCurrentConfig(config, extra); | 
|  | } | 
|  | this.doLinkCopyToClipboard(type, newUrl); | 
|  | } | 
|  | }); | 
|  | } | 
|  |  | 
|  | private displayTooltip(where: JQuery, message: string): void { | 
|  | where.tooltip('dispose'); | 
|  | where.tooltip({ | 
|  | placement: 'bottom', | 
|  | trigger: 'manual', | 
|  | title: message, | 
|  | }); | 
|  | where.tooltip('show'); | 
|  | // Manual triggering of tooltips does not hide them automatically. This timeout ensures they do | 
|  | setTimeout(() => where.tooltip('hide'), 1500); | 
|  | } | 
|  |  | 
|  | private openShareModalForType(type: LinkType): void { | 
|  | switch (type) { | 
|  | case LinkType.Short: | 
|  | this.shareShort.trigger('click'); | 
|  | break; | 
|  | case LinkType.Full: | 
|  | this.shareFull.trigger('click'); | 
|  | break; | 
|  | case LinkType.Embed: | 
|  | this.shareEmbed.trigger('click'); | 
|  | break; | 
|  | } | 
|  | } | 
|  |  | 
|  | private doLinkCopyToClipboard(type: LinkType, link: string): void { | 
|  | if (Sharing.isNavigatorClipboardAvailable()) { | 
|  | navigator.clipboard.writeText(link) | 
|  | .then(() => this.displayTooltip(this.share, 'Link copied to clipboard')) | 
|  | .catch(() => this.openShareModalForType(type)); | 
|  | } else { | 
|  | this.openShareModalForType(type); | 
|  | } | 
|  | } | 
|  |  | 
|  | public static getLinks(config: any, currentBind: LinkType, done: CallableFunction): void { | 
|  | const root = window.httpRoot; | 
|  | ga.proxy('send', { | 
|  | hitType: 'event', | 
|  | eventCategory: 'CreateShareLink', | 
|  | eventAction: 'Sharing', | 
|  | }); | 
|  | switch (currentBind) { | 
|  | case LinkType.Short: | 
|  | Sharing.getShortLink(config, root, done); | 
|  | return; | 
|  | case LinkType.Full: | 
|  | done(null, window.location.origin + root + '#' + url.serialiseState(config), false); | 
|  | return; | 
|  | case LinkType.Embed: { | 
|  | const options = {}; | 
|  | $('#sharelinkdialog input:checked').each((i, element) => { | 
|  | options[$(element).prop('class')] = true; | 
|  | }); | 
|  | done(null, Sharing.getEmbeddedHtml(config, root, false, options), false); | 
|  | return; | 
|  | } | 
|  | default: | 
|  | // Hmmm | 
|  | done('Unknown link type', null); | 
|  | } | 
|  | } | 
|  |  | 
|  | private static getShortLink(config: any, root: string, done: CallableFunction): void { | 
|  | const useExternalShortener = options.urlShortenService !== 'default'; | 
|  | const 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: (result: any) => { | 
|  | const pushState = useExternalShortener ? null : result.url; | 
|  | done(null, result.url, pushState, true); | 
|  | }, | 
|  | error: (err) => { | 
|  | // Notify the user that we ran into trouble? | 
|  | done(err.statusText, null, false); | 
|  | }, | 
|  | cache: true, | 
|  | }); | 
|  | } | 
|  |  | 
|  | private static getEmbeddedHtml(config, root, isReadOnly, extraOptions): string { | 
|  | const embedUrl = Sharing.getEmbeddedUrl(config, root, isReadOnly, extraOptions); | 
|  | return `<iframe width="800px" height="200px" src="${embedUrl}"></iframe>`; | 
|  | } | 
|  |  | 
|  | private static getEmbeddedUrl(config: any, root: string, readOnly: boolean, extraOptions: object): string { | 
|  | const location = window.location.origin + root; | 
|  | const parameters = _.reduce(extraOptions, (total, value, key): string => { | 
|  | if (total === '') { | 
|  | total = '?'; | 
|  | } else { | 
|  | total += '&'; | 
|  | } | 
|  |  | 
|  | return total + key + '=' + value; | 
|  | }, ''); | 
|  |  | 
|  | const path = (readOnly ? 'embed-ro' : 'e') + parameters + '#'; | 
|  |  | 
|  | return location + path + url.serialiseState(config); | 
|  | } | 
|  |  | 
|  | private static storeCurrentConfig(config: any, extra: string): void { | 
|  | window.history.pushState(null, '', extra); | 
|  | } | 
|  |  | 
|  | private static isNavigatorClipboardAvailable(): boolean { | 
|  | return navigator.clipboard != null; | 
|  | } | 
|  |  | 
|  | public static filterComponentState(config: any, keysToRemove: [string] = ['selection']): any { | 
|  | function filterComponentStateImpl(component: any) { | 
|  | if (component.content) { | 
|  | for (let i = 0; i < component.content.length; i++) { | 
|  | filterComponentStateImpl(component.content[i]); | 
|  | } | 
|  | } | 
|  |  | 
|  | if (component.componentState) { | 
|  | Object.keys(component.componentState) | 
|  | .filter((e) => keysToRemove.includes(e)) | 
|  | .forEach((key) => delete component.componentState[key]); | 
|  | } | 
|  | } | 
|  |  | 
|  | config = cloneDeep(config); | 
|  | filterComponentStateImpl(config); | 
|  | return config; | 
|  | } | 
|  |  | 
|  | private static updateShares(container: JQuery, url: string): void { | 
|  | const baseTemplate = $('#share-item'); | 
|  | _.each(shareServices, (service, serviceName) => { | 
|  | const newElement = baseTemplate.children('a.share-item').clone(); | 
|  | if (service.logoClass) { | 
|  | newElement.prepend($('<span>') | 
|  | .addClass('dropdown-icon mr-1') | 
|  | .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); | 
|  | }); | 
|  | } | 
|  | } |