diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index ec443ae5d0..91c30908bb 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -576,11 +576,14 @@ MenuButtons--index--metaInfo-button = MenuButtons--index--full-view = Full View MenuButtons--index--cancel-upload = Cancel Upload -MenuButtons--index--share-upload = - .label = Upload Local Profile +MenuButtons--index--download = + .label = Download… -MenuButtons--index--share-re-upload = - .label = Re-upload +MenuButtons--index--share = + .label = Share… + +MenuButtons--index--reshare = + .label = Re-share… MenuButtons--index--share-error-uploading = .label = Error uploading @@ -758,9 +761,11 @@ MenuButtons--publish--renderCheckbox-label-private-browsing-warning-image = MenuButtons--publish--renderCheckbox-label-argument-values = Include JavaScript execution tracing function argument values MenuButtons--publish--renderCheckbox-label-argument-values-warning-image = .title = This profile contains function argument values recorded from the page, which may include personal data -MenuButtons--publish--reupload-performance-profile = Re-upload Performance Profile MenuButtons--publish--share-performance-profile = Share Performance Profile +MenuButtons--publish--reshare-performance-profile = Re-share Performance Profile +MenuButtons--publish--download-performance-profile = Download Performance Profile MenuButtons--publish--info-description = Upload your profile and make it accessible to anyone with the link. +MenuButtons--publish--download-info-description = Save this profile as a file on your computer. MenuButtons--publish--info-description-default = By default, your personal data is removed. MenuButtons--publish--info-description-firefox-nightly2 = This profile is from { -firefox-nightly-brand-name }, so by default most information is included. MenuButtons--publish--include-additional-data = Include additional data that may be identifiable diff --git a/src/actions/publish.ts b/src/actions/publish.ts index 5b427b929f..f4b3869bd2 100644 --- a/src/actions/publish.ts +++ b/src/actions/publish.ts @@ -58,27 +58,33 @@ import type { Pid, TrackIndex, ProfileIndexTranslationMaps, + ProfileEncodingResult, + SharingMode, } from 'firefox-profiler/types'; import { compress } from 'firefox-profiler/utils/gz'; import { serializeProfileToJsonString } from 'firefox-profiler/profile-logic/process-profile'; export function updateSharingOption( + mode: SharingMode, slug: keyof CheckedSharingOptions, value: boolean ): Action { return { type: 'UPDATE_SHARING_OPTION', + mode, slug, value, }; } export function sanitizedProfileEncodingStarted( - sanitizedProfile: Profile + sanitizedProfile: Profile, + encodingPromise: Promise ): Action { return { type: 'SANITIZED_PROFILE_ENCODING_STARTED', sanitizedProfile, + encodingPromise, }; } @@ -176,8 +182,10 @@ async function persistJustUploadedProfileInformationToDb( const urlPredictor = getUrlPredictor(prepublishedState); let predictedUrl; - const removeProfileInformation = - getRemoveProfileInformation(prepublishedState); + const removeProfileInformation = getRemoveProfileInformation( + prepublishedState, + 'upload' + ); if (removeProfileInformation) { // In case you wonder, committedRanges is either an empty array (if the // range was sanitized) or `null` (otherwise). @@ -253,13 +261,6 @@ async function persistJustUploadedProfileInformationToDb( } } -export type ProfileEncodingResult = - | { - type: 'SUCCESS'; - profileData: Blob; - } - | { type: 'ERROR'; error: Error }; - function unwrapEncodedProfile(encodingResult: ProfileEncodingResult): Blob { if (encodingResult.type === 'ERROR') { throw encodingResult.error; @@ -278,31 +279,33 @@ export type InflightProfileEncoding = { * - Serialize the profile to a buffer * - Kick off the asynchronous compression of the buffer * - * The asynchronous compression can take a few seconds, so we want to kick - * it off immediately when the profile publishing panel is opened. We also - * want to be able to make use of the current in-flight compression if the - * user clicks the upload button before compression is done. This is why - * we return an `InflightProfileEncoding` object from this action; it contains - * a promise which lets other parts of the publishing pipeline wait on the - * compressed results. + * The asynchronous compression can take a few seconds, so we kick it off + * immediately when the download or share panel is opened. The in-flight + * compression is tracked in the redux state, so opening the other panel or + * pressing Upload reuses it (for the same sanitized profile) instead of + * compressing again. The returned promise lets the caller wait on the + * compressed result. * * This thunk action is synchronous. */ export function encodeSanitizedProfile( - previousInflightEncoding?: InflightProfileEncoding + mode: SharingMode ): ThunkAction { return (dispatch, getState): InflightProfileEncoding => { const state = getState(); - const sanitizedProfile = getSanitizedProfile(state).profile; - - if (previousInflightEncoding?.sanitizedProfile === sanitizedProfile) { - // No need to kick of another compression. The current encoding may still - // be in-flight, and returning the original promise allows the caller to - // await it. - return previousInflightEncoding; - } + const sanitizedProfile = getSanitizedProfile(state, mode).profile; const encodingState = getSanitizedProfileEncodingState(state); + if ( + encodingState.phase === 'ENCODING' && + encodingState.sanitizedProfile === sanitizedProfile + ) { + // A compression for this profile is already in-flight; reuse it. + return { + sanitizedProfile, + encodingPromise: encodingState.encodingPromise, + }; + } if ( encodingState.phase === 'DONE' && encodingState.sanitizedProfile === sanitizedProfile @@ -320,8 +323,10 @@ export function encodeSanitizedProfile( // Kick off a new encoding for this profile. Don't await the promise, // just return it as part of the InflightProfileEncoding. const encodingPromise: Promise = (async function () { + // Yield so the ENCODING phase (dispatched below) is in the store before a + // synchronous failure here could dispatch FAILED, which the reducer drops. + await Promise.resolve(); try { - dispatch(sanitizedProfileEncodingStarted(sanitizedProfile)); const gzipData = await compress( serializeProfileToJsonString(sanitizedProfile) ); @@ -335,25 +340,24 @@ export function encodeSanitizedProfile( } })(); + dispatch( + sanitizedProfileEncodingStarted(sanitizedProfile, encodingPromise) + ); return { sanitizedProfile, encodingPromise }; }; } /** - * This function starts the profile sharing process. Takes an optional argument that - * indicates if the share attempt is being made for the second time. We have two share - * buttons, one for sharing for the first time, and one for sharing after the initial - * share depending on the previous URL share status. People can decide to remove the - * URLs from the profile after sharing with URLs or they can decide to add the URLs after - * sharing without them. We check the current state before attempting to share depending - * on that flag. + * This function starts the profile sharing process. + * + * There are two share buttons: one for the first upload, and one to re-share + * afterwards, which lets people add or remove the profile's URLs between + * attempts. * * The return value is used for tests to determine if the request went all the way * through (true) or was quit early due to the generation value being invalidated (false). */ -export function attemptToPublish( - previousInflightEncoding?: InflightProfileEncoding -): ThunkAction> { +export function attemptToPublish(): ThunkAction> { return async (dispatch, getState) => { try { sendAnalytics({ @@ -386,10 +390,11 @@ export function attemptToPublish( }; dispatch(uploadCompressionStarted(abortfunction)); - const sanitizedInformation = getSanitizedProfile(prePublishedState); - const profileEncoding = dispatch( - encodeSanitizedProfile(previousInflightEncoding) + const sanitizedInformation = getSanitizedProfile( + prePublishedState, + 'upload' ); + const profileEncoding = dispatch(encodeSanitizedProfile('upload')); const encodingResult = await profileEncoding.encodingPromise; // The previous line was async, check to make sure that this request is still valid. @@ -431,8 +436,10 @@ export function attemptToPublish( return false; } - const removeProfileInformation = - getRemoveProfileInformation(prePublishedState); + const removeProfileInformation = getRemoveProfileInformation( + prePublishedState, + 'upload' + ); if (removeProfileInformation) { const { committedRanges, translationMaps, profile } = sanitizedInformation; diff --git a/src/components/app/MenuButtons/Publish.css b/src/components/app/MenuButtons/Publish.css index 35048455a9..4d6d4ca9b1 100644 --- a/src/components/app/MenuButtons/Publish.css +++ b/src/components/app/MenuButtons/Publish.css @@ -17,6 +17,13 @@ background-image: var(--internal-uploading-icon); } +.menuButtonsDownloadButton::before { + background-image: url(../../../../res/img/svg/download.svg); + + /* download.svg is 14x20, so size by height to fit the square icon slot. */ + background-size: auto 12px; +} + .menuButtonsShareButtonError { --internal-error-foreground-color: white; --internal-error-background-color: var(--red-60); @@ -58,6 +65,10 @@ --internal-uploading-icon: url(../../../../res/img/svg/sharing-animated-dark-12.svg); } + .menuButtonsDownloadButton::before { + background-image: url(../../../../res/img/svg/download-light.svg); + } + .menuButtonsShareButtonError { --internal-error-foreground-color: var(--grey-20); } @@ -75,22 +86,22 @@ .publishPanelContent { position: relative; - /* This aligns all content, except the big icon. */ - padding-left: 70px; + /* Reserve space on the left for the title icon. */ + padding-left: 34px; } .publishPanelTitle { - /* "60px" This is the value to put the background image at the right location. - * This background image is 44x44, so this puts it 16px left of the text. */ - padding-left: 60px; - margin: 0 0 0 -60px; + padding-left: 28px; + + /* Negative margin pulls the 18px icon back into the gutter, 10px before the text. */ + margin: 0 0 6px -28px; background: var(--internal-info-icon) left center no-repeat; - line-height: 44px; /* This is the height of the background image */ + background-size: 18px 18px; + line-height: 28px; } .publishPanelInfoDescription { - flex: 1; - margin-bottom: 1em; + margin: 0 0 12px; line-height: 1.5; } @@ -111,7 +122,7 @@ .publishPanelButtons { display: flex; justify-content: right; - margin-top: 20px; + margin-top: 16px; } .publishPanelButton { @@ -155,6 +166,10 @@ background: var(--internal-download-icon) center center no-repeat; } +.publishPanelButtonsDownloadPrimary .publishPanelButtonsSvgDownload { + background-image: url(../../../../res/img/svg/download-light.svg); +} + .menuButtonsDownloadSize { display: inline-block; margin: 0 4px; diff --git a/src/components/app/MenuButtons/Publish.tsx b/src/components/app/MenuButtons/Publish.tsx index 32a37d37af..413b8c49f6 100644 --- a/src/components/app/MenuButtons/Publish.tsx +++ b/src/components/app/MenuButtons/Publish.tsx @@ -4,7 +4,6 @@ import * as React from 'react'; import classNames from 'classnames'; -import type { InflightProfileEncoding } from 'firefox-profiler/actions/publish'; import { updateSharingOption, attemptToPublish, @@ -42,6 +41,7 @@ import WarningImage from 'firefox-profiler-res/img/svg/warning.svg'; import type { Profile, CheckedSharingOptions, + SharingMode, StartEndRange, UploadPhase, SanitizedProfileEncodingState, @@ -51,6 +51,7 @@ import './Publish.css'; import { Localized } from '@fluent/react'; type OwnProps = { + readonly mode: SharingMode; readonly isRepublish?: boolean; }; @@ -81,19 +82,19 @@ type DispatchProps = { type PublishProps = ConnectedProps; class PublishPanelImpl extends React.PureComponent { - _inflightEncoding: InflightProfileEncoding | undefined; - override componentDidMount(): void { - this._inflightEncoding = this.props.encodeSanitizedProfile(); + this.props.encodeSanitizedProfile(this.props.mode); } _onCheckboxChange = (e: React.ChangeEvent) => { const sharingOption = e.target.name as keyof CheckedSharingOptions; - this.props.updateSharingOption(sharingOption, e.target.checked); - - this._inflightEncoding = this.props.encodeSanitizedProfile( - this._inflightEncoding + this.props.updateSharingOption( + this.props.mode, + sharingOption, + e.target.checked ); + + this.props.encodeSanitizedProfile(this.props.mode); }; _renderCheckbox( @@ -101,7 +102,9 @@ class PublishPanelImpl extends React.PureComponent { labelL10nId: string, additionalContent?: React.ReactNode ) { - const { checkedSharingOptions } = this.props; + const { checkedSharingOptions, uploadPhase } = this.props; + const isUploading = + uploadPhase === 'uploading' || uploadPhase === 'compressing'; return (