diff --git a/packages/vscode-extension/src/common/utils.ts b/packages/vscode-extension/src/common/utils.ts index 43ed9fcbe..63203b498 100644 --- a/packages/vscode-extension/src/common/utils.ts +++ b/packages/vscode-extension/src/common/utils.ts @@ -1,5 +1,14 @@ +import { TelemetryEventProperties } from "@vscode/extension-telemetry"; import { RecordingData } from "./Project"; +export interface UtilsEventListener { + (event: T): void; +} + +export interface UtilsEventMap { + telemetryEnabledChanged: boolean; +} + export interface UtilsInterface { getCommandsCurrentKeyBinding(commandName: string): Promise; @@ -16,4 +25,18 @@ export interface UtilsInterface { openExternalUrl(uriString: string): Promise; log(type: "info" | "error" | "warn" | "log", message: string, ...args: any[]): Promise; + + sendTelemetry(eventName: string, properties?: TelemetryEventProperties): Promise; + + isTelemetryEnabled(): Promise; + + addListener( + eventType: K, + listener: UtilsEventListener + ): Promise; + + removeListener( + eventType: K, + listener: UtilsEventListener + ): Promise; } diff --git a/packages/vscode-extension/src/utilities/utils.ts b/packages/vscode-extension/src/utilities/utils.ts index 774ae37a9..0e6ebc718 100644 --- a/packages/vscode-extension/src/utilities/utils.ts +++ b/packages/vscode-extension/src/utilities/utils.ts @@ -1,15 +1,18 @@ import { homedir } from "node:os"; +import { EventEmitter } from "stream"; import fs from "fs"; import path from "path"; import { commands, env, Uri, window } from "vscode"; import JSON5 from "json5"; import vscode from "vscode"; +import { TelemetryEventProperties } from "@vscode/extension-telemetry"; import { Logger } from "../Logger"; import { extensionContext } from "./extensionContext"; import { openFileAtPosition } from "./openFileAtPosition"; -import { UtilsInterface } from "../common/utils"; +import { UtilsEventListener, UtilsEventMap, UtilsInterface } from "../common/utils"; import { Platform } from "./platform"; import { RecordingData } from "../common/Project"; +import { getTelemetryReporter } from "./telemetry"; type KeybindingType = { command: string; @@ -19,6 +22,14 @@ type KeybindingType = { }; export class Utils implements UtilsInterface { + private eventEmitter = new EventEmitter(); + + constructor() { + vscode.env.onDidChangeTelemetryEnabled((telemetryEnabled) => { + this.eventEmitter.emit("telemetryEnabledChanged", telemetryEnabled); + }); + } + public async getCommandsCurrentKeyBinding(commandName: string) { const packageJsonPath = path.join(extensionContext.extensionPath, "package.json"); const extensionPackageJson = require(packageJsonPath); @@ -142,4 +153,25 @@ export class Utils implements UtilsInterface { // Combine into the desired format return `${year}-${month}-${day} ${hours}.${minutes}.${seconds}`; } + + public async sendTelemetry(eventName: string, properties?: TelemetryEventProperties) { + getTelemetryReporter().sendTelemetryEvent(eventName, properties); + } + + public async isTelemetryEnabled() { + return vscode.env.isTelemetryEnabled; + } + + async addListener( + eventType: K, + listener: UtilsEventListener + ) { + this.eventEmitter.addListener(eventType, listener); + } + async removeListener( + eventType: K, + listener: UtilsEventListener + ) { + this.eventEmitter.removeListener(eventType, listener); + } } diff --git a/packages/vscode-extension/src/webview/components/Feedback.css b/packages/vscode-extension/src/webview/components/Feedback.css new file mode 100644 index 000000000..e6e83b6ea --- /dev/null +++ b/packages/vscode-extension/src/webview/components/Feedback.css @@ -0,0 +1,76 @@ +.feedback { + display: flex; + gap: 4px; +} + +.feedback-button { + width: 26px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; + transition: all 250ms 0ms ease-in-out; +} + +.feedback-button:active { + transform: scale(0.94); +} + +.feedback-button-large:active { + transform: scale(0.96); +} + +.feedback-button:hover span, +.feedback-button-positive-active span, +.feedback-button-negative-active span { + color: var(--swm-popover-background) !important; +} + +.feedback-button:hover { + cursor: pointer; +} + +.feedback-button-large { + width: 64px; + height: 46px; +} + +.feedback-button-large span { + font-size: 24px !important; +} + +.feedback-button-positive { + color: var(--green-light-80); + border: 1px solid var(--green-light-80); +} + +.feedback-button-positive span { + color: var(--green-light-80); +} + +.feedback-button-positive:hover, +.feedback-button-positive-active { + background-color: var(--green-light-80); +} + +.feedback-button-negative { + border: 1px solid var(--red-light-80); +} + +.feedback-button-negative span { + color: var(--red-light-80); +} + +.feedback-button-negative:hover, +.feedback-button-negative-active { + background-color: var(--red-light-80); +} + +.feedback-prompt { + cursor: pointer; + font-size: 12px; +} +.feedback-prompt:hover { + text-decoration: underline; +} diff --git a/packages/vscode-extension/src/webview/components/Feedback.tsx b/packages/vscode-extension/src/webview/components/Feedback.tsx new file mode 100644 index 000000000..a0c37bca2 --- /dev/null +++ b/packages/vscode-extension/src/webview/components/Feedback.tsx @@ -0,0 +1,77 @@ +import { Dispatch, MouseEvent, SetStateAction } from "react"; +import "./Feedback.css"; +import classNames from "classnames"; +import { useUtils } from "../providers/UtilsProvider"; +import { Sentiment } from "./SendFeedbackItem"; + +type FeedbackProps = { + sentiment: Sentiment | undefined; + setSentiment: Dispatch>; +}; + +export function Feedback({ sentiment, setSentiment }: FeedbackProps) { + const { sendTelemetry } = useUtils(); + + const handleFeedback = (event: MouseEvent, pickedSentiment: Sentiment) => { + event.preventDefault(); + sendTelemetry(`feedback:${pickedSentiment}`); + setSentiment(pickedSentiment); + }; + + return ( +
+ {Boolean(sentiment) ? ( +

+ {sentiment === "positive" ? "What went well?" : "Tell us more"} +

+ ) : ( + <> + handleFeedback(e, "positive")} /> + handleFeedback(e, "negative")} /> + + )} +
+ ); +} + +type FeedbackButtonProps = { + sentiment: Sentiment; + onClick?: (event: MouseEvent) => void; + type?: "small" | "large"; + isActive?: boolean; +}; + +export function FeedbackButton({ + sentiment, + onClick, + type = "small", + isActive, +}: FeedbackButtonProps) { + if (sentiment === "positive") { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/vscode-extension/src/webview/components/SendFeedbackItem.tsx b/packages/vscode-extension/src/webview/components/SendFeedbackItem.tsx new file mode 100644 index 000000000..6f380918e --- /dev/null +++ b/packages/vscode-extension/src/webview/components/SendFeedbackItem.tsx @@ -0,0 +1,31 @@ +import { useState } from "react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; + +import { useModal } from "../providers/ModalProvider"; +import FeedbackView from "../views/FeedbackView"; +import { Feedback } from "./Feedback"; + +export type Sentiment = "positive" | "negative"; + +export function SendFeedbackItem() { + const { openModal } = useModal(); + + const [sentiment, setSentiment] = useState(); + + return ( + { + openModal( + "Do you enjoy using Radon IDE today?", + + ); + }}> + +
+ Send feedback + +
+
+ ); +} diff --git a/packages/vscode-extension/src/webview/components/SettingsDropdown.tsx b/packages/vscode-extension/src/webview/components/SettingsDropdown.tsx index 7e0d59b7d..32d146b31 100644 --- a/packages/vscode-extension/src/webview/components/SettingsDropdown.tsx +++ b/packages/vscode-extension/src/webview/components/SettingsDropdown.tsx @@ -11,6 +11,8 @@ import { KeybindingInfo } from "./shared/KeybindingInfo"; import { useUtils } from "../providers/UtilsProvider"; import "./shared/SwitchGroup.css"; import LaunchConfigurationView from "../views/LaunchConfigurationView"; +import { SendFeedbackItem } from "./SendFeedbackItem"; +import { useTelemetry } from "../providers/TelemetryProvider"; interface SettingsDropdownProps { children: React.ReactNode; @@ -23,6 +25,7 @@ function SettingsDropdown({ project, isDeviceRunning, children, disabled }: Sett const { panelLocation, update } = useWorkspaceConfig(); const { openModal } = useModal(); const { movePanelToNewWindow, reportIssue } = useUtils(); + const { telemetryEnabled } = useTelemetry(); return ( @@ -142,6 +145,7 @@ function SettingsDropdown({ project, isDeviceRunning, children, disabled }: Sett
Report Issue
+ {telemetryEnabled && }
diff --git a/packages/vscode-extension/src/webview/index.jsx b/packages/vscode-extension/src/webview/index.jsx index 1e793bec6..3089383f4 100644 --- a/packages/vscode-extension/src/webview/index.jsx +++ b/packages/vscode-extension/src/webview/index.jsx @@ -11,6 +11,7 @@ import WorkspaceConfigProvider from "./providers/WorkspaceConfigProvider"; import "./styles/theme.css"; import { UtilsProvider, installLogOverrides } from "./providers/UtilsProvider"; +import { TelemetryProvider } from "./providers/TelemetryProvider"; import LaunchConfigProvider from "./providers/LaunchConfigProvider"; installLogOverrides(); @@ -22,19 +23,21 @@ root.render( - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/packages/vscode-extension/src/webview/providers/TelemetryProvider.tsx b/packages/vscode-extension/src/webview/providers/TelemetryProvider.tsx new file mode 100644 index 000000000..2f69bb5a5 --- /dev/null +++ b/packages/vscode-extension/src/webview/providers/TelemetryProvider.tsx @@ -0,0 +1,33 @@ +import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from "react"; +import { useUtils } from "./UtilsProvider"; + +type TelemetryContextProps = { telemetryEnabled: boolean }; + +const TelemetryContext = createContext({ telemetryEnabled: false }); + +export function TelemetryProvider({ children }: PropsWithChildren) { + const utils = useUtils(); + const [telemetryEnabled, setTelemetryEnabled] = useState(false); + + useEffect(() => { + utils.isTelemetryEnabled().then(setTelemetryEnabled); + utils.addListener("telemetryEnabledChanged", setTelemetryEnabled); + + return () => { + utils.removeListener("telemetryEnabledChanged", setTelemetryEnabled); + }; + }, []); + + const contextValue = useMemo(() => ({ telemetryEnabled }), [telemetryEnabled]); + + return {children}; +} + +export function useTelemetry() { + const context = useContext(TelemetryContext); + + if (context === undefined) { + throw new Error("useTelemetry must be used within a TelemetryContextProvider"); + } + return context; +} diff --git a/packages/vscode-extension/src/webview/views/FeedbackView.css b/packages/vscode-extension/src/webview/views/FeedbackView.css new file mode 100644 index 000000000..2270cfa05 --- /dev/null +++ b/packages/vscode-extension/src/webview/views/FeedbackView.css @@ -0,0 +1,71 @@ +.feedback-textarea { + flex: 1; + background-color: var(--swm-input-background); + color: var(--swm-default-text); + height: auto; + padding: 8px; + margin: 0 4px; + border: 1px; + border-radius: 4px; + line-height: 1.5; + box-shadow: var(--swm-input-shadow); + font-family: var(--font-family); + resize: none; +} + +.feedback-textarea::placeholder { + color: var(--swm-secondary-text); +} + +.feedback-buttons-container { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin: 16px 0; +} + +.feedback-button-wrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.feedback-button-label { + color: var(--swm-default-text); + font-size: 12px; + margin-top: 8px; +} + +.feedback-label-optional { + color: var(--swm-secondary-text); + font-weight: normal; + margin-left: 0; +} + +.feedback-report-issue { + line-height: 1.5; + color: var(--swm-secondary-text); +} + +.feedback-report-issue a { + color: var(--swm-default-text); +} + +.feedback-report-issue a:hover { + text-decoration: underline; +} + +.feedback-row { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.feedback-thank-you { + color: var(--swm-default-text); + font-weight: 500; + font-size: 18px; + text-align: center; +} diff --git a/packages/vscode-extension/src/webview/views/FeedbackView.tsx b/packages/vscode-extension/src/webview/views/FeedbackView.tsx new file mode 100644 index 000000000..5513c3eb5 --- /dev/null +++ b/packages/vscode-extension/src/webview/views/FeedbackView.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; +import Button from "../components/shared/Button"; +import { FeedbackButton } from "../components/Feedback"; +import "./FeedbackView.css"; +import { useModal } from "../providers/ModalProvider"; +import { useUtils } from "../providers/UtilsProvider"; +import { Sentiment } from "../components/SendFeedbackItem"; + +const CLOSE_MODAL_AFTER = 2400; + +type FeedbackViewProps = { + initialSentiment: Sentiment | undefined; +}; + +function FeedbackView({ initialSentiment }: FeedbackViewProps) { + const [isFeedbackSent, setFeedbackSent] = useState(false); + const { closeModal, showHeader } = useModal(); + const { register, handleSubmit } = useForm(); + const { sendTelemetry } = useUtils(); + const [sentiment, setSentiment] = useState(initialSentiment); + + const onSubmit: SubmitHandler = (e) => { + const { message } = e; + sendTelemetry(`feedback:${sentiment}`, { message }); + showHeader(false); + setFeedbackSent(true); + }; + + useEffect(() => { + if (isFeedbackSent) { + const timer = setTimeout(() => { + closeModal(); + showHeader(true); + }, CLOSE_MODAL_AFTER); + return () => clearTimeout(timer); + } + }, [isFeedbackSent]); + + if (isFeedbackSent) { + return

Thank you for your feedback!

; + } + + return ( +
+
+
+ setSentiment("positive")} + isActive={sentiment === "positive"} + /> + I do! +
+
+ setSentiment("negative")} + isActive={sentiment === "negative"} + /> + Not really... +
+
+