blob: 9f56ddebc4f2cfa5c2a82516f494a80038c37c77 [file] [log] [blame] [raw]
RabsRincon6c053b82021-08-27 22:08:27 +02001// Copyright (c) 2021, Compiler Explorer Authors
2// All rights reserved.
3//
4// Redistribution and use in source and binary forms, with or without
5// modification, are permitted provided that the following conditions are met:
6//
7// * Redistributions of source code must retain the above copyright notice,
8// this list of conditions and the following disclaimer.
9// * Redistributions in binary form must reproduce the above copyright
10// notice, this list of conditions and the following disclaimer in the
11// documentation and/or other materials provided with the distribution.
12//
13// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
14// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
15// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
16// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
17// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
19// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
20// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
21// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
22// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
23// POSSIBILITY OF SUCH DAMAGE.
24
25import $ from 'jquery';
26import Sentry from '@sentry/browser';
27import GoldenLayout from 'golden-layout';
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +010028import _ from 'underscore';
RabsRincon9b176562021-08-27 23:49:14 +020029import ClipboardJS from 'clipboard';
RabsRincon6c053b82021-08-27 22:08:27 +020030
31import ClickEvent = JQuery.ClickEvent;
32import TriggeredEvent = JQuery.TriggeredEvent;
33
Mats Larsen025325e2021-10-12 10:30:10 +010034const ga = require('./analytics').ga;
35const options = require('./options').options;
RabsRincon6c053b82021-08-27 22:08:27 +020036const url = require('./url');
37const cloneDeep = require('lodash.clonedeep');
38
39enum LinkType {
40 Short,
41 Full,
42 Embed
43}
44
45const shareServices = {
46 twitter: {
47 embedValid: false,
48 logoClass: 'fab fa-twitter',
49 cssClass: 'share-twitter',
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +010050 getLink: (title, url) => {
51 return 'https://twitter.com/intent/tweet' +
52 `?text=${encodeURIComponent(title)}` +
53 `&url=${encodeURIComponent(url)}` +
54 '&via=CompileExplore';
55 },
RabsRincon6c053b82021-08-27 22:08:27 +020056 text: 'Tweet',
57 },
58 reddit: {
59 embedValid: false,
60 logoClass: 'fab fa-reddit',
61 cssClass: 'share-reddit',
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +010062 getLink: (title, url) => {
63 return 'http://www.reddit.com/submit' +
64 `?url=${encodeURIComponent(url)}` +
65 `&title=${encodeURIComponent(title)}`;
66 },
RabsRincon6c053b82021-08-27 22:08:27 +020067 text: 'Share on Reddit',
68 },
69};
70
71export class Sharing {
72 private layout: GoldenLayout;
73 private lastState: any;
74
75 private share: JQuery;
76 private shareShort: JQuery;
77 private shareFull: JQuery;
78 private shareEmbed: JQuery;
79
Rubén Rincón Blancodb229b92022-02-12 02:41:01 +010080 private clippyButton: ClipboardJS | null;
RabsRincon9b176562021-08-27 23:49:14 +020081
RabsRincon6c053b82021-08-27 22:08:27 +020082 constructor(layout: any) {
83 this.layout = layout;
84 this.lastState = null;
85
86 this.share = $('#share');
87 this.shareShort = $('#shareShort');
88 this.shareFull = $('#shareFull');
89 this.shareEmbed = $('#shareEmbed');
90
RabsRincon9b176562021-08-27 23:49:14 +020091 this.clippyButton = null;
92
RabsRincon6c053b82021-08-27 22:08:27 +020093 this.initButtons();
94 this.initCallbacks();
95 }
96
97 private initCallbacks(): void {
RabsRinconcfbcf4e2021-09-03 07:09:09 +020098 this.layout.eventHub.on('displaySharingPopover', () => {
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +010099 this.openShareModalForType(LinkType.Short);
RabsRinconcfbcf4e2021-09-03 07:09:09 +0200100 });
Luca Natilla4af294d2022-02-01 04:28:56 +0100101 this.layout.eventHub.on('copyShortLinkToClip', () => {
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100102 this.copyLinkTypeToClipboard(LinkType.Short);
Luca Natilla4af294d2022-02-01 04:28:56 +0100103 });
RabsRincon6c053b82021-08-27 22:08:27 +0200104 this.layout.on('stateChanged', this.onStateChanged.bind(this));
105
RabsRincon9b176562021-08-27 23:49:14 +0200106 $('#sharelinkdialog').on('show.bs.modal', this.onOpenModalPane.bind(this))
107 .on('hidden.bs.modal', this.onCloseModalPane.bind(this));
RabsRincon6c053b82021-08-27 22:08:27 +0200108 }
109
110 private onStateChanged(): void {
111 const config = Sharing.filterComponentState(this.layout.toConfig());
112 this.ensureUrlIsNotOutdated(config);
113 if (options.embedded) {
114 const strippedToLast = window.location.pathname.substr(0, window.location.pathname.lastIndexOf('/') + 1);
115 $('a.link').prop('href', strippedToLast + '#' + url.serialiseState(config));
116 }
117 }
118
119 private ensureUrlIsNotOutdated(config: any): void {
120 const stringifiedConfig = JSON.stringify(config);
121 if (stringifiedConfig !== this.lastState) {
122 if (this.lastState != null && window.location.pathname !== window.httpRoot) {
Rubén Rincón Blancodb229b92022-02-12 02:41:01 +0100123 window.history.replaceState(null, '', window.httpRoot);
RabsRincon6c053b82021-08-27 22:08:27 +0200124 }
125 this.lastState = stringifiedConfig;
126 }
127 }
128
129 private static bindToLinkType(bind: string): LinkType {
130 switch (bind) {
131 case 'Full': return LinkType.Full;
132 case 'Short': return LinkType.Short;
133 case 'Embed': return LinkType.Embed;
134 default: return LinkType.Full;
135 }
136 }
137
138 private onOpenModalPane(event: TriggeredEvent<HTMLElement, undefined, HTMLElement, HTMLElement>): void {
139 // @ts-ignore The property is added by bootstrap
140 const button = $(event.relatedTarget);
141 const currentBind = Sharing.bindToLinkType(button.data('bind'));
142 const modal = $(event.currentTarget);
143 const socialSharingElements = modal.find('.socialsharing');
144 const permalink = modal.find('.permalink');
145 const embedsettings = modal.find('#embedsettings');
146
147 const updatePermaLink = () => {
148 socialSharingElements.empty();
149 const config = this.layout.toConfig();
150 Sharing.getLinks(config, currentBind, (error: any, newUrl: string, extra: string, updateState: boolean) => {
Rubén Rincón Blanco6a1dbe12021-09-03 05:51:05 +0200151 permalink.off('click');
RabsRincon6c053b82021-08-27 22:08:27 +0200152 if (error || !newUrl) {
153 permalink.prop('disabled', true);
154 permalink.val(error || 'Error providing URL');
155 Sentry.captureException(error);
156 } else {
157 if (updateState) {
158 Sharing.storeCurrentConfig(config, extra);
159 }
160 permalink.val(newUrl);
Rubén Rincón Blanco6a1dbe12021-09-03 05:51:05 +0200161 permalink.on('click', () => {
162 permalink.trigger('focus').trigger('select');
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100163 });
RabsRincon6c053b82021-08-27 22:08:27 +0200164 if (options.sharingEnabled) {
165 Sharing.updateShares(socialSharingElements, newUrl);
166 // Disable the links for every share item which does not support embed html as links
167 if (currentBind === LinkType.Embed) {
168 socialSharingElements.children('.share-no-embeddable')
Rubén Rincón Blanco6a1dbe12021-09-03 05:51:05 +0200169 .hide()
RabsRincon6c053b82021-08-27 22:08:27 +0200170 .on('click', false);
171 }
172 }
173 }
174 });
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100175 };
RabsRincon6c053b82021-08-27 22:08:27 +0200176
Rubén Rincón Blancodb229b92022-02-12 02:41:01 +0100177 const clippyElement = modal.find('button.clippy').get(0);
178 if (clippyElement != null) {
179 this.clippyButton = new ClipboardJS(clippyElement);
180 this.clippyButton.on('success', (e) => {
181 this.displayTooltip(permalink, 'Link copied to clipboard');
182 e.clearSelection();
183 });
184 this.clippyButton.on('error', (e) => {
185 this.displayTooltip(permalink, 'Error copying to clipboard');
186 });
187 }
RabsRincon9b176562021-08-27 23:49:14 +0200188
RabsRincon6c053b82021-08-27 22:08:27 +0200189 if (currentBind === LinkType.Embed) {
190 embedsettings.show();
191 embedsettings.find('input')
192 // Off any prev click handlers to avoid multiple events triggering after opening the modal more than once
193 .off('click')
194 .on('click', () => updatePermaLink());
195 } else {
196 embedsettings.hide();
197 }
198
199 updatePermaLink();
200
201 ga.proxy('send', {
202 hitType: 'event',
203 eventCategory: 'OpenModalPane',
204 eventAction: 'Sharing',
205 });
RabsRincon9b176562021-08-27 23:49:14 +0200206 }
207
208 private onCloseModalPane(): void {
209 if (this.clippyButton) {
210 this.clippyButton.destroy();
211 this.clippyButton = null;
212 }
RabsRincon6c053b82021-08-27 22:08:27 +0200213 }
RabsRinconc9e8e7a2021-09-03 07:11:58 +0200214
RabsRincon6c053b82021-08-27 22:08:27 +0200215 private initButtons(): void {
216 const shareShortCopyToClipBtn = this.shareShort.find('.clip-icon');
217 const shareFullCopyToClipBtn = this.shareFull.find('.clip-icon');
218 const shareEmbedCopyToClipBtn = this.shareEmbed.find('.clip-icon');
219
RabsRinconcfbcf4e2021-09-03 07:09:09 +0200220 shareShortCopyToClipBtn.on('click', (e) => this.onClipButtonPressed(e, LinkType.Short));
221 shareFullCopyToClipBtn.on('click', (e) => this.onClipButtonPressed(e, LinkType.Full));
222 shareEmbedCopyToClipBtn.on('click', (e) => this.onClipButtonPressed(e, LinkType.Embed));
RabsRincon9f1373b2021-09-02 08:53:20 +0200223
224 if (options.sharingEnabled) {
225 Sharing.updateShares($('#socialshare'), window.location.protocol + '//' + window.location.hostname);
226 }
RabsRincon6c053b82021-08-27 22:08:27 +0200227 }
228
229 private onClipButtonPressed(event: ClickEvent, type: LinkType): void {
230 // Dont let the modal show up.
231 // We need this because the button is a child of the dropdown-item with a data-toggle=modal
RabsRinconcfbcf4e2021-09-03 07:09:09 +0200232 if (Sharing.isNavigatorClipboardAvailable()) {
233 event.stopPropagation();
234 this.copyLinkTypeToClipboard(type);
235 // As we prevented bubbling, the dropdown won't close by itself. We need to trigger it manually
236 this.share.dropdown('hide');
237 }
RabsRincon6c053b82021-08-27 22:08:27 +0200238 }
239
240 private copyLinkTypeToClipboard(type: LinkType): void {
241 const config = this.layout.toConfig();
242 Sharing.getLinks(config, type, (error: any, newUrl: string, extra: string, updateState: boolean) => {
243 if (error || !newUrl) {
RabsRincon9b176562021-08-27 23:49:14 +0200244 this.displayTooltip(this.share, 'Oops, something went wrong');
RabsRincon6c053b82021-08-27 22:08:27 +0200245 Sentry.captureException(error);
246 } else {
247 if (updateState) {
248 Sharing.storeCurrentConfig(config, extra);
249 }
RabsRinconcfbcf4e2021-09-03 07:09:09 +0200250 this.doLinkCopyToClipboard(type, newUrl);
RabsRincon6c053b82021-08-27 22:08:27 +0200251 }
252 });
253 }
254
RabsRincon9b176562021-08-27 23:49:14 +0200255 private displayTooltip(where: JQuery, message: string): void {
256 where.tooltip('dispose');
257 where.tooltip({
RabsRincon6c053b82021-08-27 22:08:27 +0200258 placement: 'bottom',
259 trigger: 'manual',
260 title: message,
261 });
RabsRincon9b176562021-08-27 23:49:14 +0200262 where.tooltip('show');
RabsRincon6c053b82021-08-27 22:08:27 +0200263 // Manual triggering of tooltips does not hide them automatically. This timeout ensures they do
RabsRincon9b176562021-08-27 23:49:14 +0200264 setTimeout(() => where.tooltip('hide'), 1500);
RabsRincon6c053b82021-08-27 22:08:27 +0200265 }
266
RabsRinconcfbcf4e2021-09-03 07:09:09 +0200267 private openShareModalForType(type: LinkType): void {
268 switch (type) {
269 case LinkType.Short:
270 this.shareShort.trigger('click');
271 break;
272 case LinkType.Full:
273 this.shareFull.trigger('click');
274 break;
275 case LinkType.Embed:
276 this.shareEmbed.trigger('click');
277 break;
278 }
279 }
280
281 private doLinkCopyToClipboard(type: LinkType, link: string): void {
RabsRincon6c053b82021-08-27 22:08:27 +0200282 if (Sharing.isNavigatorClipboardAvailable()) {
283 navigator.clipboard.writeText(link)
RabsRincon9b176562021-08-27 23:49:14 +0200284 .then(() => this.displayTooltip(this.share, 'Link copied to clipboard'))
RabsRinconcfbcf4e2021-09-03 07:09:09 +0200285 .catch(() => this.openShareModalForType(type));
286 } else {
287 this.openShareModalForType(type);
RabsRincon6c053b82021-08-27 22:08:27 +0200288 }
289 }
290
291 public static getLinks(config: any, currentBind: LinkType, done: CallableFunction): void {
292 const root = window.httpRoot;
293 ga.proxy('send', {
294 hitType: 'event',
295 eventCategory: 'CreateShareLink',
296 eventAction: 'Sharing',
297 });
298 switch (currentBind) {
299 case LinkType.Short:
300 Sharing.getShortLink(config, root, done);
301 return;
302 case LinkType.Full:
303 done(null, window.location.origin + root + '#' + url.serialiseState(config), false);
304 return;
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100305 case LinkType.Embed: {
RabsRincon6c053b82021-08-27 22:08:27 +0200306 const options = {};
307 $('#sharelinkdialog input:checked').each((i, element) => {
308 options[$(element).prop('class')] = true;
309 });
310 done(null, Sharing.getEmbeddedHtml(config, root, false, options), false);
311 return;
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100312 }
RabsRincon6c053b82021-08-27 22:08:27 +0200313 default:
314 // Hmmm
315 done('Unknown link type', null);
316 }
317 }
318
319 private static getShortLink(config: any, root: string, done: CallableFunction): void {
320 const useExternalShortener = options.urlShortenService !== 'default';
321 const data = JSON.stringify({
322 config: useExternalShortener ? url.serialiseState(config) : config,
323 });
324 $.ajax({
325 type: 'POST',
326 url: window.location.origin + root + 'api/shortener',
327 dataType: 'json', // Expected
328 contentType: 'application/json', // Sent
329 data: data,
330 success: (result: any) => {
331 const pushState = useExternalShortener ? null : result.url;
332 done(null, result.url, pushState, true);
333 },
334 error: (err) => {
335 // Notify the user that we ran into trouble?
336 done(err.statusText, null, false);
337 },
338 cache: true,
339 });
340 }
341
342 private static getEmbeddedHtml(config, root, isReadOnly, extraOptions): string {
343 const embedUrl = Sharing.getEmbeddedUrl(config, root, isReadOnly, extraOptions);
344 return `<iframe width="800px" height="200px" src="${embedUrl}"></iframe>`;
345 }
346
347 private static getEmbeddedUrl(config: any, root: string, readOnly: boolean, extraOptions: object): string {
348 const location = window.location.origin + root;
349 const parameters = _.reduce(extraOptions, (total, value, key): string => {
350 if (total === '') {
351 total = '?';
352 } else {
353 total += '&';
354 }
355
356 return total + key + '=' + value;
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100357 }, '');
RabsRincon6c053b82021-08-27 22:08:27 +0200358
359 const path = (readOnly ? 'embed-ro' : 'e') + parameters + '#';
360
361 return location + path + url.serialiseState(config);
362 }
363
364 private static storeCurrentConfig(config: any, extra: string): void {
Rubén Rincón Blancodb229b92022-02-12 02:41:01 +0100365 window.history.pushState(null, '', extra);
RabsRincon6c053b82021-08-27 22:08:27 +0200366 }
RabsRincon6c053b82021-08-27 22:08:27 +0200367
368 private static isNavigatorClipboardAvailable(): boolean {
369 return navigator.clipboard != null;
Rubén Rincón Blanco42c7b2b2022-02-03 18:04:50 +0100370 }
RabsRincon6c053b82021-08-27 22:08:27 +0200371
372 public static filterComponentState(config: any, keysToRemove: [string] = ['selection']): any {
373 function filterComponentStateImpl(component: any) {
374 if (component.content) {
375 for (let i = 0; i < component.content.length; i++) {
376 filterComponentStateImpl(component.content[i]);
377 }
378 }
379
380 if (component.componentState) {
381 Object.keys(component.componentState)
382 .filter((e) => keysToRemove.includes(e))
383 .forEach((key) => delete component.componentState[key]);
384 }
385 }
386
387 config = cloneDeep(config);
388 filterComponentStateImpl(config);
389 return config;
390 }
391
392 private static updateShares(container: JQuery, url: string): void {
393 const baseTemplate = $('#share-item');
394 _.each(shareServices, (service, serviceName) => {
395 const newElement = baseTemplate.children('a.share-item').clone();
396 if (service.logoClass) {
397 newElement.prepend($('<span>')
Rubén Rincón Blanco6a1dbe12021-09-03 05:51:05 +0200398 .addClass('dropdown-icon mr-1')
RabsRincon6c053b82021-08-27 22:08:27 +0200399 .addClass(service.logoClass)
400 .prop('title', serviceName)
401 );
402 }
403 if (service.text) {
404 newElement.children('span.share-item-text')
Rubén Rincón Blanco6a1dbe12021-09-03 05:51:05 +0200405 .text(service.text);
RabsRincon6c053b82021-08-27 22:08:27 +0200406 }
407 newElement
408 .prop('href', service.getLink('Compiler Explorer', url))
409 .addClass(service.cssClass)
410 .toggleClass('share-no-embeddable', !service.embedValid)
411 .appendTo(container);
412 });
413 }
414}