/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 'use strict'; import 'vs/css!./media/feedback'; import nls = require('vs/nls'); import { IDisposable } from 'vs/base/common/lifecycle'; import { Builder, $ } from 'vs/base/browser/builder'; import { Dropdown } from 'vs/base/browser/ui/dropdown/dropdown'; import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import product from 'vs/platform/node/product'; import * as dom from 'vs/base/browser/dom'; import { ICommandService } from 'vs/platform/commands/common/commands'; import * as errors from 'vs/base/common/errors'; import { IIntegrityService } from 'vs/platform/integrity/common/integrity'; import { IThemeService, registerThemingParticipant, ITheme, ICssStyleCollector } from 'vs/platform/theme/common/themeService'; import { attachStylerCallback } from 'vs/platform/theme/common/styler'; import { editorWidgetBackground, widgetShadow, inputBorder, inputForeground, inputBackground, inputActiveOptionBorder, editorBackground, buttonBackground, contrastBorder, darken } from 'vs/platform/theme/common/colorRegistry'; import { IWorkspaceConfigurationService } from 'vs/workbench/services/configuration/common/configuration'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; import { IAnchor } from 'vs/base/browser/ui/contextview/contextview'; export const FEEDBACK_VISIBLE_CONFIG = 'workbench.statusBar.feedback.visible'; export interface IFeedback { feedback: string; sentiment: number; } export interface IFeedbackService { submitFeedback(feedback: IFeedback): void; getCharacterLimit(sentiment: number): number; } export interface IFeedbackDropdownOptions { contextViewProvider: IContextViewService; feedbackService?: IFeedbackService; onFeedbackVisibilityChange?: (visible: boolean) => void; } enum FormEvent { SENDING, SENT, SEND_ERROR } export class FeedbackDropdown extends Dropdown { private maxFeedbackCharacters: number; private feedback: string; private sentiment: number; private isSendingFeedback: boolean; private autoHideTimeout: number; private feedbackService: IFeedbackService; private feedbackForm: HTMLFormElement; private feedbackDescriptionInput: HTMLTextAreaElement; private smileyInput: Builder; private frownyInput: Builder; private sendButton: Builder; private hideButton: HTMLInputElement; private remainingCharacterCount: Builder; private requestFeatureLink: string; private _isPure: boolean; constructor( container: HTMLElement, private options: IFeedbackDropdownOptions, @ICommandService private commandService: ICommandService, @ITelemetryService private telemetryService: ITelemetryService, @IIntegrityService private integrityService: IIntegrityService, @IThemeService private themeService: IThemeService, @IWorkspaceConfigurationService private configurationService: IWorkspaceConfigurationService ) { super(container, { contextViewProvider: options.contextViewProvider, labelRenderer: (container: HTMLElement): IDisposable => { $(container).addClass('send-feedback', 'mask-icon'); return null; } }); this._isPure = true; this.integrityService.isPure().then(result => { if (!result.isPure) { this._isPure = false; } }); this.element.addClass('send-feedback'); this.element.title(nls.localize('sendFeedback', "Tweet Feedback")); this.feedbackService = options.feedbackService; this.feedback = ''; this.sentiment = 1; this.maxFeedbackCharacters = this.feedbackService.getCharacterLimit(this.sentiment); this.feedbackForm = null; this.feedbackDescriptionInput = null; this.smileyInput = null; this.frownyInput = null; this.sendButton = null; this.requestFeatureLink = product.sendASmile.requestFeatureUrl; } protected getAnchor(): HTMLElement | IAnchor { const res = dom.getDomNodePagePosition(this.element.getHTMLElement()); return { x: res.left, y: res.top - 9, /* above the status bar */ width: res.width, height: res.height } as IAnchor; } protected renderContents(container: HTMLElement): IDisposable { const $form = $('form.feedback-form').attr({ action: 'javascript:void(0);' }).appendTo(container); $(container).addClass('monaco-menu-container'); this.feedbackForm = $form.getHTMLElement(); $('h2.title').text(nls.localize("label.sendASmile", "Tweet us your feedback.")).appendTo($form); const cancelBtn = $('div.cancel').attr('tabindex', '0'); cancelBtn.on(dom.EventType.MOUSE_OVER, () => { const theme = this.themeService.getTheme(); let darkenFactor: number; switch (theme.type) { case 'light': darkenFactor = 0.1; break; case 'dark': darkenFactor = 0.2; break; } if (darkenFactor) { cancelBtn.getHTMLElement().style.backgroundColor = darken(theme.getColor(editorWidgetBackground), darkenFactor)(theme).toString(); } }); cancelBtn.on(dom.EventType.MOUSE_OUT, () => { cancelBtn.getHTMLElement().style.backgroundColor = null; }); this.invoke(cancelBtn, () => { this.hide(); }).appendTo($form); const $content = $('div.content').appendTo($form); const $sentimentContainer = $('div').appendTo($content); if (!this._isPure) { $('span').text(nls.localize("patchedVersion1", "Your installation is corrupt.")).appendTo($sentimentContainer); $('br').appendTo($sentimentContainer); $('span').text(nls.localize("patchedVersion2", "Please specify this if you submit a bug.")).appendTo($sentimentContainer); $('br').appendTo($sentimentContainer); } $('span').text(nls.localize("sentiment", "How was your experience?")).appendTo($sentimentContainer); const $feedbackSentiment = $('div.feedback-sentiment').appendTo($sentimentContainer); this.smileyInput = $('div').addClass('sentiment smile').attr({ 'aria-checked': 'false', 'aria-label': nls.localize('smileCaption', "Happy"), 'tabindex': 0, 'role': 'checkbox' }); this.invoke(this.smileyInput, () => { this.setSentiment(true); }).appendTo($feedbackSentiment); this.frownyInput = $('div').addClass('sentiment frown').attr({ 'aria-checked': 'false', 'aria-label': nls.localize('frownCaption', "Sad"), 'tabindex': 0, 'role': 'checkbox' }); this.invoke(this.frownyInput, () => { this.setSentiment(false); }).appendTo($feedbackSentiment); if (this.sentiment === 1) { this.smileyInput.addClass('checked').attr('aria-checked', 'true'); } else { this.frownyInput.addClass('checked').attr('aria-checked', 'true'); } const $contactUs = $('div.contactus').appendTo($content); $('span').text(nls.localize("other ways to contact us", "Other ways to contact us")).appendTo($contactUs); const $contactUsContainer = $('div.channels').appendTo($contactUs); $('div').append($('a').attr('target', '_blank').attr('href', '#').text(nls.localize("submit a bug", "Submit a bug")).attr('tabindex', '0')) .on('click', event => { dom.EventHelper.stop(event); const actionId = 'workbench.action.openIssueReporter'; this.commandService.executeCommand(actionId).done(null, errors.onUnexpectedError); /* __GDPR__ "workbenchActionExecuted" : { "id" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "from": { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ this.telemetryService.publicLog('workbenchActionExecuted', { id: actionId, from: 'feedback' }); }) .appendTo($contactUsContainer); $('div').append($('a').attr('target', '_blank').attr('href', this.requestFeatureLink).text(nls.localize("request a missing feature", "Request a missing feature")).attr('tabindex', '0')) .appendTo($contactUsContainer); this.remainingCharacterCount = $('span.char-counter').text(this.getCharCountText(0)); $('h3').text(nls.localize("tell us why?", "Tell us why?")) .append(this.remainingCharacterCount) .appendTo($form); this.feedbackDescriptionInput = $('textarea.feedback-description').attr({ rows: 3, maxlength: this.maxFeedbackCharacters, 'aria-label': nls.localize("commentsHeader", "Comments") }) .text(this.feedback).attr('required', 'required') .on('keyup', () => { this.updateCharCountText(); }) .appendTo($form).domFocus().getHTMLElement(); const $buttons = $('div.form-buttons').appendTo($form); const $hideButtonContainer = $('div.hide-button-container').appendTo($buttons); this.hideButton = $('input.hide-button').type('checkbox').attr('checked', '').id('hide-button').appendTo($hideButtonContainer).getHTMLElement() as HTMLInputElement; $('label').attr('for', 'hide-button').text(nls.localize('showFeedback', "Show Feedback Smiley in Status Bar")).appendTo($hideButtonContainer); this.sendButton = this.invoke($('input.send').type('submit').attr('disabled', '').value(nls.localize('tweet', "Tweet")).appendTo($buttons), () => { if (this.isSendingFeedback) { return; } this.onSubmit(); }); this.toDispose.push(attachStylerCallback(this.themeService, { widgetShadow, editorWidgetBackground, inputBackground, inputForeground, inputBorder, editorBackground, contrastBorder }, colors => { $form.style('background-color', colors.editorWidgetBackground); $form.style('box-shadow', colors.widgetShadow ? `0 0 8px ${colors.widgetShadow}` : null); if (this.feedbackDescriptionInput) { this.feedbackDescriptionInput.style.backgroundColor = colors.inputBackground; this.feedbackDescriptionInput.style.color = colors.inputForeground; this.feedbackDescriptionInput.style.border = `1px solid ${colors.inputBorder || 'transparent'}`; } $contactUs.style('background-color', colors.editorBackground); $contactUs.style('border', `1px solid ${colors.contrastBorder || 'transparent'}`); })); return { dispose: () => { this.feedbackForm = null; this.feedbackDescriptionInput = null; this.smileyInput = null; this.frownyInput = null; } }; } private getCharCountText(charCount: number): string { const remaining = this.maxFeedbackCharacters - charCount; const text = (remaining === 1) ? nls.localize("character left", "character left") : nls.localize("characters left", "characters left"); return '(' + remaining + ' ' + text + ')'; } private updateCharCountText(): void { this.remainingCharacterCount.text(this.getCharCountText(this.feedbackDescriptionInput.value.length)); this.feedbackDescriptionInput.value ? this.sendButton.removeAttribute('disabled') : this.sendButton.attr('disabled', ''); } private setSentiment(smile: boolean): void { if (smile) { this.smileyInput.addClass('checked'); this.smileyInput.attr('aria-checked', 'true'); this.frownyInput.removeClass('checked'); this.frownyInput.attr('aria-checked', 'false'); } else { this.frownyInput.addClass('checked'); this.frownyInput.attr('aria-checked', 'true'); this.smileyInput.removeClass('checked'); this.smileyInput.attr('aria-checked', 'false'); } this.sentiment = smile ? 1 : 0; this.maxFeedbackCharacters = this.feedbackService.getCharacterLimit(this.sentiment); this.updateCharCountText(); $(this.feedbackDescriptionInput).attr({ maxlength: this.maxFeedbackCharacters }); } private invoke(element: Builder, callback: () => void): Builder { element.on('click', callback); element.on('keypress', (e) => { if (e instanceof KeyboardEvent) { const keyboardEvent = e; if (keyboardEvent.keyCode === 13 || keyboardEvent.keyCode === 32) { // Enter or Spacebar callback(); } } }); return element; } public show(): void { super.show(); if (this.options.onFeedbackVisibilityChange) { this.options.onFeedbackVisibilityChange(true); } } protected onHide(): void { if (this.options.onFeedbackVisibilityChange) { this.options.onFeedbackVisibilityChange(false); } } public hide(): void { if (this.feedbackDescriptionInput) { this.feedback = this.feedbackDescriptionInput.value; } if (this.autoHideTimeout) { clearTimeout(this.autoHideTimeout); this.autoHideTimeout = null; } if (this.hideButton && !this.hideButton.checked) { this.configurationService.updateValue(FEEDBACK_VISIBLE_CONFIG, false).done(null, errors.onUnexpectedError); } super.hide(); } public onEvent(e: Event, activeElement: HTMLElement): void { if (e instanceof KeyboardEvent) { const keyboardEvent = e; if (keyboardEvent.keyCode === 27) { // Escape this.hide(); } } } private onSubmit(): void { if ((this.feedbackForm.checkValidity && !this.feedbackForm.checkValidity())) { return; } this.changeFormStatus(FormEvent.SENDING); this.feedbackService.submitFeedback({ feedback: this.feedbackDescriptionInput.value, sentiment: this.sentiment }); this.changeFormStatus(FormEvent.SENT); } private changeFormStatus(event: FormEvent): void { switch (event) { case FormEvent.SENDING: this.isSendingFeedback = true; this.sendButton.setClass('send in-progress'); this.sendButton.value(nls.localize('feedbackSending', "Sending")); break; case FormEvent.SENT: this.isSendingFeedback = false; this.sendButton.setClass('send success').value(nls.localize('feedbackSent', "Thanks")); this.resetForm(); this.autoHideTimeout = setTimeout(() => { this.hide(); }, 1000); this.sendButton.off(['click', 'keypress']); this.invoke(this.sendButton, () => { this.hide(); this.sendButton.off(['click', 'keypress']); }); break; case FormEvent.SEND_ERROR: this.isSendingFeedback = false; this.sendButton.setClass('send error').value(nls.localize('feedbackSendingError', "Try again")); break; } } private resetForm(): void { if (this.feedbackDescriptionInput) { this.feedbackDescriptionInput.value = ''; } this.sentiment = 1; this.maxFeedbackCharacters = this.feedbackService.getCharacterLimit(this.sentiment); } } registerThemingParticipant((theme: ITheme, collector: ICssStyleCollector) => { // Sentiment Buttons const inputActiveOptionBorderColor = theme.getColor(inputActiveOptionBorder); if (inputActiveOptionBorderColor) { collector.addRule(`.monaco-shell .feedback-form .sentiment.checked { border: 1px solid ${inputActiveOptionBorderColor}; }`); } // Links const linkColor = theme.getColor(buttonBackground) || theme.getColor(contrastBorder); if (linkColor) { collector.addRule(`.monaco-shell .feedback-form .content .channels a { color: ${linkColor}; }`); } });