diff --git a/client/branded/src/global-styles/index.scss b/client/branded/src/global-styles/index.scss index 0d3219ab750d..3bc6cb705342 100644 --- a/client/branded/src/global-styles/index.scss +++ b/client/branded/src/global-styles/index.scss @@ -137,6 +137,7 @@ $code-bg: var(--body-bg); @import './card'; @import './dropdown'; @import './modal'; +@import './popover'; @import './nav'; @import './type'; @import './list-group'; diff --git a/client/branded/src/global-styles/popover.scss b/client/branded/src/global-styles/popover.scss new file mode 100644 index 000000000000..70818442c40d --- /dev/null +++ b/client/branded/src/global-styles/popover.scss @@ -0,0 +1,6 @@ +$popover-font-size: $font-size-base; +$popover-arrow-outer-color: var(--border-color); +$popover-arrow-color: var(--body-bg); +$popover-max-width: auto; + +@import 'bootstrap/scss/popover'; diff --git a/client/shared/src/actions/ActionItem.tsx b/client/shared/src/actions/ActionItem.tsx index 9df9bc66ac38..2a3bcf4fbe56 100644 --- a/client/shared/src/actions/ActionItem.tsx +++ b/client/shared/src/actions/ActionItem.tsx @@ -7,7 +7,7 @@ import { catchError, map, mapTo, mergeMap, startWith, tap } from 'rxjs/operators import { ExecuteCommandParams } from '../api/client/services/command' import { ActionContribution, Evaluated } from '../api/protocol' import { urlForOpenPanel } from '../commands/commands' -import { LinkOrButton } from '../components/LinkOrButton' +import { ButtonLink } from '../components/LinkOrButton' import { ExtensionsControllerProps } from '../extensions/controller' import { PlatformContextProps } from '../platform/context' import { TelemetryProps } from '../telemetry/telemetryService' @@ -206,7 +206,7 @@ export class ActionItem extends React.PureComponent { : {} return ( - { )} - + ) } diff --git a/client/shared/src/components/LinkOrButton.test.tsx b/client/shared/src/components/LinkOrButton.test.tsx index 47ef995928c9..011852b8fa3c 100644 --- a/client/shared/src/components/LinkOrButton.test.tsx +++ b/client/shared/src/components/LinkOrButton.test.tsx @@ -1,15 +1,15 @@ import React from 'react' import renderer from 'react-test-renderer' -import { LinkOrButton } from './LinkOrButton' +import { ButtonLink } from './LinkOrButton' describe('LinkOrButton', () => { test('render a link when "to" is set', () => { - const component = renderer.create(foo) + const component = renderer.create(foo) expect(component.toJSON()).toMatchSnapshot() }) test('render a button when "to" is undefined', () => { - const component = renderer.create(foo) + const component = renderer.create(foo) expect(component.toJSON()).toMatchSnapshot() }) }) diff --git a/client/shared/src/components/LinkOrButton.tsx b/client/shared/src/components/LinkOrButton.tsx index 419dab76b7d2..4d87a31be1f9 100644 --- a/client/shared/src/components/LinkOrButton.tsx +++ b/client/shared/src/components/LinkOrButton.tsx @@ -34,16 +34,17 @@ interface Props extends Pick, 'target' | 'rel'> { className?: string disabled?: boolean + + id?: string } /** * A component that is displayed in the same way, regardless of whether it's a link (with a * destination URL) or a button (with a click handler). * - * It is keyboard accessible: unlike or , pressing the enter key triggers it. Unlike - * - {open && ( - - {bodyReference => ( -
} - > -

Modal

-

You can click outside of the modal body to close me

-
- )} -
- )} - - )} - - ) -}) diff --git a/client/web/src/components/ModalContainer.tsx b/client/web/src/components/ModalContainer.tsx deleted file mode 100644 index ffec791788f6..000000000000 --- a/client/web/src/components/ModalContainer.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import CloseIcon from 'mdi-react/CloseIcon' -import React, { useCallback, useEffect, useRef } from 'react' -import classNames from 'classnames' -import { Key } from 'ts-key-enum' - -interface Props { - /** Called when user clicks outside of the modal or presses the `esc` key */ - onClose?: () => void - hideCloseIcon?: boolean - children: (bodyReference: React.MutableRefObject) => JSX.Element - className?: string -} - -export const ModalContainer: React.FunctionComponent = ({ onClose, hideCloseIcon, className, children }) => { - const containerReference = useRef(null) - const modalBodyReference = useRef(null) - - // TODO(tj): tabtrapping modal body - - // On first render, close over the element that was focused to open it. - // On unmount, refocus that element - useEffect(() => { - const focusedElement = document.activeElement - - containerReference.current?.focus() - - return () => { - if (focusedElement && focusedElement instanceof HTMLElement) { - focusedElement.focus() - } - } - }, []) - - // Close modal when user clicks outside of modal body - // (optional behavior: user opts in by using `bodyReference` as the ref attribute to the body element) - useEffect(() => { - function handleMouseDownOutside(event: MouseEvent): void { - const modalBody = modalBodyReference.current - if (onClose && modalBody && modalBody !== event.target && !modalBody.contains(event.target as Node)) { - document.addEventListener('mouseup', handleMouseUp) - } - } - - // Only called when mousedown was outside of the modal body - function handleMouseUp(event: MouseEvent): void { - document.removeEventListener('mouseup', handleMouseUp) - - const modalBody = modalBodyReference.current - // if mouse is still outside of modal body, close the modal - if (onClose && modalBody && modalBody !== event.target && !modalBody.contains(event.target as Node)) { - onClose() - } - } - - document.addEventListener('mousedown', handleMouseDownOutside) - - return () => { - document.removeEventListener('mousedown', handleMouseDownOutside) - // just in case (e.g. modal could close from a timeout between mousedown and mouseup) - document.removeEventListener('mouseup', handleMouseUp) - } - }, [onClose]) - - // Close modal when user presses `esc` key - const onKeyDown: React.KeyboardEventHandler = useCallback( - event => { - if (event.key === Key.Escape) { - onClose?.() - } - }, - [onClose] - ) - - return ( -
-
-
- {onClose && !hideCloseIcon && ( - - - - )} -
- {children(modalBodyReference)} -
-
- ) -} diff --git a/client/web/src/components/shared.tsx b/client/web/src/components/shared.tsx index 676873b122b6..8e3840cfaa2d 100644 --- a/client/web/src/components/shared.tsx +++ b/client/web/src/components/shared.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect } from 'react' import { ActionsNavItems, ActionsNavItemsProps } from '../../../shared/src/actions/ActionsNavItems' import { CommandListPopoverButton, CommandListPopoverButtonProps } from '../../../shared/src/commandPalette/CommandList' import { @@ -9,10 +9,11 @@ import { import { isErrorLike } from '../../../shared/src/util/errors' import { HoverOverlay, HoverOverlayProps } from '../../../shared/src/hover/HoverOverlay' import { useLocalStorage } from '../util/useLocalStorage' +import { HoverThresholdProps } from '../repo/RepoContainer' // Components from shared with web-styling class names applied -export const WebHoverOverlay: React.FunctionComponent = props => { +export const WebHoverOverlay: React.FunctionComponent = props => { const [dismissedAlerts, setDismissedAlerts] = useLocalStorage('WebHoverOverlay.dismissedAlerts', []) const onAlertDismissed = useCallback( (alertType: string) => { @@ -31,6 +32,18 @@ export const WebHoverOverlay: React.FunctionComponent = props propsToUse = { ...props, hoverOrError: { ...props.hoverOrError, alerts: filteredAlerts } } } + const { hoverOrError } = propsToUse + const { onHoverShown, hoveredToken } = props + + /** Whether the hover has actual content (that provides value to the user) */ + const hoverHasValue = hoverOrError !== 'loading' && !isErrorLike(hoverOrError) && !!hoverOrError?.contents?.length + + useEffect(() => { + if (hoverHasValue) { + onHoverShown?.() + } + }, [hoveredToken?.filePath, hoveredToken?.line, hoveredToken?.character, onHoverShown, hoverHasValue]) + return ( !element.hasAttribute('disabled') && element instanceof HTMLElement) +} + +/** + * Utility hook for modal-type components (e.g. ModalContainer, PopoverContainer). + * `useModality` adds focus trapping and intuitive close logic to modal containers. + */ +export function useModality( + onClose?: () => void, + targetID?: string +): { + modalContainerReference: React.MutableRefObject + modalBodyReference: React.MutableRefObject +} { + const modalContainerReference = useRef(null) + const modalBodyReference = useRef(null) + + // On first render, close over the element that was focused to open it. on unmount, refocus that element. + // Add keydown event listener for: 1) focus trapping, 2) `esc` to close + useEffect(() => { + const focusedElement = document.activeElement + + // TODO: use body ref instead? + const containerElement = modalContainerReference.current + containerElement?.focus() + + function onKeyDownContainer(event: KeyboardEvent): void { + if (event.key === Key.Escape) { + onClose?.() + } + + // focus trapping + if (containerElement) { + const focusableElements = getFocusableElements(containerElement) + + const firstFocusable = focusableElements[0] + const lastFocusable = focusableElements[focusableElements.length - 1] + + if (event.key === Key.Tab) { + if (event.shiftKey) { + if (document.activeElement === containerElement) { + // don't let the user escape (container is focused when modal is opened) + event.preventDefault() + return + } + if (document.activeElement === firstFocusable) { + // if this is the first focusable element, focus the last focusable element + event.preventDefault() + lastFocusable.focus() + } + } else if (document.activeElement === lastFocusable) { + // if this is the last focusable element, focus the first focusable element + event.preventDefault() + firstFocusable.focus() + } + } + } + } + + containerElement?.addEventListener('keydown', onKeyDownContainer) + + return () => { + if (focusedElement && focusedElement instanceof HTMLElement) { + focusedElement.focus() + } + containerElement?.removeEventListener('keydown', onKeyDownContainer) + } + }, [modalBodyReference, modalContainerReference, onClose]) + + // Close modal when user clicks outside of modal body, or clicks the target element again (for popovers) + // (optional behavior: user opts in by using `bodyReference` as the ref attribute to the body element and/or id of target) + useEffect(() => { + function handleMouseDownOutside(event: MouseEvent): void { + const modalBody = modalBodyReference.current + const targetElement = targetID ? document.querySelector(`#${targetID}`) : null + + if (modalBody || targetElement) { + const isNotModalBody = + !modalBody || (modalBody !== event.target && !modalBody.contains(event.target as Node)) + const isNotTargetElement = + !targetElement || (targetElement !== event.target && !targetElement.contains(event.target as Node)) + + if (onClose && isNotModalBody && isNotTargetElement) { + document.addEventListener('mouseup', handleMouseUp) + } + } + } + + // Only called when mousedown was outside of the modal body + function handleMouseUp(event: MouseEvent): void { + document.removeEventListener('mouseup', handleMouseUp) + + const modalBody = modalBodyReference.current + const targetElement = targetID ? document.querySelector(`#${targetID}`) : null + + // if mouse is still outside of modal body, close the modal + const isNotModalBody = + !modalBody || (modalBody !== event.target && !modalBody.contains(event.target as Node)) + const isNotTargetElement = + !targetElement || (targetElement !== event.target && !targetElement.contains(event.target as Node)) + + if (onClose && isNotModalBody && isNotTargetElement) { + onClose() + } + } + + document.addEventListener('mousedown', handleMouseDownOutside) + + return () => { + document.removeEventListener('mousedown', handleMouseDownOutside) + // just in case (e.g. modal could close from a timeout between mousedown and mouseup) + document.removeEventListener('mouseup', handleMouseUp) + } + }, [modalBodyReference, onClose, targetID]) + + return { + modalBodyReference, + modalContainerReference, + } +} diff --git a/client/web/src/extensions/ExtensionPermissionModal.scss b/client/web/src/extensions/ExtensionPermissionModal.scss index 0a15fe12ab54..b7b5b87a78d0 100644 --- a/client/web/src/extensions/ExtensionPermissionModal.scss +++ b/client/web/src/extensions/ExtensionPermissionModal.scss @@ -1,6 +1,17 @@ -.extension-permission-modal { +@import '~@reach/dialog/styles.css'; + +.modal-body { background-color: var(--body-bg); padding: 1rem; width: 32rem; max-width: 100vw; + + &__center { + // Center modal + transform: translate(-50%, -50%); + position: absolute; + top: 50%; + margin: 0; + left: 50%; + } } diff --git a/client/web/src/extensions/ExtensionPermissionModal.tsx b/client/web/src/extensions/ExtensionPermissionModal.tsx index 9900fa64c51d..0cc53c503865 100644 --- a/client/web/src/extensions/ExtensionPermissionModal.tsx +++ b/client/web/src/extensions/ExtensionPermissionModal.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { ModalContainer } from '../components/ModalContainer' +import { Dialog } from '@reach/dialog' import { splitExtensionID } from './extension/extension' /** @@ -11,30 +11,30 @@ export const ExtensionPermissionModal: React.FunctionComponent<{ denyPermission: () => void }> = ({ extensionID, denyPermission, givePermission }) => { const { name } = splitExtensionID(extensionID) + const labelId = `label--permission-${extensionID}` return ( - - {bodyReference => ( -
} - > -

Add {name || extensionID} Sourcegraph extension?

-

It will be able to:

-
    -
  • read repositories and files you view using Sourcegraph
  • -
  • read and change your Sourcegraph settings
  • -
-
- - -
+ +
+

Add {name || extensionID} Sourcegraph extension?

+

It will be able to:

+
    +
  • read repositories and files you view using Sourcegraph
  • +
  • read and change your Sourcegraph settings
  • +
+
+ +
- )} - +
+
) } diff --git a/client/web/src/extensions/ExtensionsArea.scss b/client/web/src/extensions/ExtensionsArea.scss index a85641b0dfed..e1653c8bde9a 100644 --- a/client/web/src/extensions/ExtensionsArea.scss +++ b/client/web/src/extensions/ExtensionsArea.scss @@ -1,6 +1,5 @@ @import './ExtensionCard'; @import './ExtensionsList'; -@import './ExtensionPermissionModal.scss'; @import './ExtensionBanner.scss'; .extensions-area { diff --git a/client/web/src/extensions/__snapshots__/ExtensionPermissionModal.test.tsx.snap b/client/web/src/extensions/__snapshots__/ExtensionPermissionModal.test.tsx.snap index b79bd65f010f..f890310d3497 100644 --- a/client/web/src/extensions/__snapshots__/ExtensionPermissionModal.test.tsx.snap +++ b/client/web/src/extensions/__snapshots__/ExtensionPermissionModal.test.tsx.snap @@ -1,65 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ExtensionPermissionModal renders 1`] = ` -
-
-
-
-

- Add - typescript - Sourcegraph extension? -

-

- It will be able to: -

-
    -
  • - read repositories and files you view using Sourcegraph -
  • -
  • - read and change your Sourcegraph settings -
  • -
-
- - -
-
-
-
-`; +exports[`ExtensionPermissionModal renders 1`] = ``; diff --git a/client/web/src/integration/blob-viewer.test.ts b/client/web/src/integration/blob-viewer.test.ts index d381891552a9..fb21c55bdc53 100644 --- a/client/web/src/integration/blob-viewer.test.ts +++ b/client/web/src/integration/blob-viewer.test.ts @@ -16,6 +16,7 @@ import { SharedGraphQlOperations } from '../../../shared/src/graphql-operations' import { Settings } from '../schema/settings.schema' import type * as sourcegraph from 'sourcegraph' import { afterEachSaveScreenshotIfFailed } from '../../../shared/src/testing/screenshotReporter' +import { Page } from 'puppeteer' describe('Blob viewer', () => { let driver: Driver @@ -138,7 +139,7 @@ describe('Blob viewer', () => { html: // Note: whitespace in this string is significant. '
// Log to console\n' + - '
console.log("Hello world")\n' + + '
console.log("Hello world")\n' + '
', }, }, @@ -215,5 +216,89 @@ describe('Blob viewer', () => { await driver.page.goto(`${driver.sourcegraphBaseUrl}/github.com/sourcegraph/test/-/test.ts#2:9`) // TODO }) + + describe('browser extension discoverability', () => { + const HOVER_THRESHOLD = 3 + const HOVER_COUNT_KEY = 'hover-count' + it(`shows a popover about the browser extension when the user has seen ${HOVER_THRESHOLD} hovers and clicks "View on [code host]" button`, async () => { + await driver.page.goto(`${driver.sourcegraphBaseUrl}/github.com/sourcegraph/test/-/blob/test.ts`) + await driver.page.evaluate(() => localStorage.removeItem('hover-count')) + await driver.page.reload() + + await driver.page.waitForSelector('.test-go-to-code-host', { visible: true }) + // Close new tab after clicking link + const newPage = new Promise(resolve => + driver.browser.once('targetcreated', target => resolve(target.page())) + ) + await driver.page.click('.test-go-to-code-host', { button: 'middle' }) + await (await newPage).close() + + assert( + !(await driver.page.$('.test-install-browser-extension-popover')), + 'Expected popover to not be displayed before user reaches hover threshold' + ) + + // Click 'console' and 'log' 5 times combined + await driver.page.waitForSelector('.test-log-token', { visible: true }) + for (let index = 0; index < HOVER_THRESHOLD; index++) { + await driver.page.click(index % 2 === 0 ? '.test-log-token' : '.test-console-token') + await driver.page.waitForSelector('.hover-overlay', { visible: true }) + } + + await driver.page.click('.test-go-to-code-host', { button: 'middle' }) + await driver.page.waitForSelector('.test-install-browser-extension-popover', { visible: true }) + assert( + !!(await driver.page.$('.test-install-browser-extension-popover')), + 'Expected popover to be displayed after user reaches hover threshold' + ) + + const popoverHeader = await driver.page.evaluate( + () => document.querySelector('.test-install-browser-extension-popover-header')?.textContent + ) + assert.strictEqual( + popoverHeader, + "Take Sourcegraph's code intelligence to GitHub!", + 'Expected popover header text to reflect code host' + ) + }) + + it(`shows an alert about the browser extension when the user has seen ${HOVER_THRESHOLD} hovers`, async () => { + await driver.page.goto(`${driver.sourcegraphBaseUrl}/github.com/sourcegraph/test/-/blob/test.ts`) + await driver.page.evaluate(HOVER_COUNT_KEY => localStorage.removeItem(HOVER_COUNT_KEY), HOVER_COUNT_KEY) + await driver.page.reload() + + // Alert should not be visible before the user reaches the hover threshold + assert( + !(await driver.page.$('.install-browser-extension-alert')), + 'Expected "Install browser extension" alert to not be displayed before user reaches hover threshold' + ) + + // Click 'console' and 'log' $HOVER_THRESHOLD times combined + await driver.page.waitForSelector('.test-log-token', { visible: true }) + for (let index = 0; index < HOVER_THRESHOLD; index++) { + await driver.page.click(index % 2 === 0 ? '.test-log-token' : '.test-console-token') + await driver.page.waitForSelector('.hover-overlay', { visible: true }) + } + await driver.page.reload() + + await driver.page.waitForSelector('.repo-header') + // Alert should be visible now that the user has seen $HOVER_THRESHOLD hovers + assert( + !!(await driver.page.$('.install-browser-extension-alert')), + 'Expected "Install browser extension" alert to be displayed after user reaches hover threshold' + ) + + // Dismiss alert + await driver.page.click('.test-close-alert') + await driver.page.reload() + + await driver.page.waitForSelector('.repo-header') + // Alert should not show up now that the user has dismissed it once + assert( + !(await driver.page.$('.install-browser-extension-alert')), + 'Expected "Install browser extension" alert to not be displayed before user dismisses it once' + ) + }) + }) }) }) diff --git a/client/web/src/nav/GlobalNavbar.story.tsx b/client/web/src/nav/GlobalNavbar.story.tsx index c28b9f0c53fe..d974b3f301aa 100644 --- a/client/web/src/nav/GlobalNavbar.story.tsx +++ b/client/web/src/nav/GlobalNavbar.story.tsx @@ -55,6 +55,7 @@ const defaultProps = ( isLightTheme: props.isLightTheme, navbarSearchQueryState: { cursorPosition: 0, query: '' }, onNavbarQueryChange: () => {}, + isExtensionAlertAnimating: false, showCampaigns: true, activation: undefined, hideNavLinks: false, diff --git a/client/web/src/nav/GlobalNavbar.test.tsx b/client/web/src/nav/GlobalNavbar.test.tsx index c91faae42635..bb4b2deed7f4 100644 --- a/client/web/src/nav/GlobalNavbar.test.tsx +++ b/client/web/src/nav/GlobalNavbar.test.tsx @@ -33,6 +33,7 @@ const PROPS: React.ComponentProps = { telemetryService: {} as any, hideNavLinks: true, // used because reactstrap Popover is incompatible with react-test-renderer filtersInQuery: {} as any, + isExtensionAlertAnimating: false, splitSearchModes: false, interactiveSearchMode: false, toggleSearchMode: () => undefined, diff --git a/client/web/src/nav/GlobalNavbar.tsx b/client/web/src/nav/GlobalNavbar.tsx index 722d8f9afe6e..348cd5207fb0 100644 --- a/client/web/src/nav/GlobalNavbar.tsx +++ b/client/web/src/nav/GlobalNavbar.tsx @@ -31,6 +31,7 @@ import { VersionContext } from '../schema/site.schema' import { TelemetryProps } from '../../../shared/src/telemetry/telemetryService' import { BrandLogo } from '../components/branding/BrandLogo' import { LinkOrSpan } from '../../../shared/src/components/LinkOrSpan' +import { ExtensionAlertAnimationProps } from './UserNavItem' interface Props extends SettingsCascadeProps, @@ -40,6 +41,7 @@ interface Props TelemetryProps, ThemeProps, ThemePreferenceProps, + ExtensionAlertAnimationProps, ActivationProps, PatternTypeProps, CaseSensitivityProps, diff --git a/client/web/src/nav/NavLinks.test.tsx b/client/web/src/nav/NavLinks.test.tsx index 3d671339561f..a12a01c0e80b 100644 --- a/client/web/src/nav/NavLinks.test.tsx +++ b/client/web/src/nav/NavLinks.test.tsx @@ -92,6 +92,7 @@ describe('NavLinks', () => { authenticatedUser={authenticatedUser} showDotComMarketing={showDotComMarketing} location={H.createLocation(path, history.location)} + isExtensionAlertAnimating={false} /> ) diff --git a/client/web/src/nav/NavLinks.tsx b/client/web/src/nav/NavLinks.tsx index 58311bea8bea..8c2cecc631de 100644 --- a/client/web/src/nav/NavLinks.tsx +++ b/client/web/src/nav/NavLinks.tsx @@ -11,7 +11,7 @@ import { SettingsCascadeProps } from '../../../shared/src/settings/settings' import { WebActionsNavItems, WebCommandListPopoverButton } from '../components/shared' import { ThemeProps } from '../../../shared/src/theme' import { StatusMessagesNavItem } from './StatusMessagesNavItem' -import { UserNavItem } from './UserNavItem' +import { ExtensionAlertAnimationProps, UserNavItem } from './UserNavItem' import { CampaignsNavItem } from '../enterprise/campaigns/global/nav/CampaignsNavItem' import { ThemePreferenceProps } from '../theme' import { @@ -33,6 +33,7 @@ interface Props PlatformContextProps<'forceUpdateTooltip' | 'settings' | 'sourcegraphURL'>, ThemeProps, ThemePreferenceProps, + ExtensionAlertAnimationProps, TelemetryProps, ActivationProps { location: H.Location diff --git a/client/web/src/nav/UserNavItem.scss b/client/web/src/nav/UserNavItem.scss index 1a9cc1464e10..85abbf62812e 100644 --- a/client/web/src/nav/UserNavItem.scss +++ b/client/web/src/nav/UserNavItem.scss @@ -2,4 +2,68 @@ &__dropdown-menu { min-width: 12rem; } + + &__tooltip { + opacity: 0; + animation: tooltip-fade-in-out 5s ease-out 100ms; + } + + &__avatar-background { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + border-radius: 50%; + opacity: 0; + animation: background-fade-in-out 5s ease-out 100ms; + + background: rgba(56, 117, 127, 0.3); + filter: blur(1px); + } +} + +@keyframes tooltip-fade-in-out { + 0% { + opacity: 0; + } + + 10% { + opacity: 1; + } + + 84% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +@keyframes background-fade-in-out { + 0% { + opacity: 0; + transform: scale(1, 1); + } + + 11% { + opacity: 0; + transform: scale(1, 1); + } + + 27% { + opacity: 1; + transform: scale(1.5, 1.5); + } + + 84% { + opacity: 1; + transform: scale(1.5, 1.5); + } + + 100% { + opacity: 0; + transform: scale(1, 1); + } } diff --git a/client/web/src/nav/UserNavItem.story.tsx b/client/web/src/nav/UserNavItem.story.tsx index 22201fb653ad..d1a6835fa21a 100644 --- a/client/web/src/nav/UserNavItem.story.tsx +++ b/client/web/src/nav/UserNavItem.story.tsx @@ -1,7 +1,7 @@ import { action } from '@storybook/addon-actions' import { boolean } from '@storybook/addon-knobs' import { storiesOf } from '@storybook/react' -import React, { useCallback } from 'react' +import React from 'react' import { ThemePreference } from '../theme' import { UserNavItem } from './UserNavItem' import { WebStory } from '../components/WebStory' @@ -10,22 +10,14 @@ const onThemePreferenceChange = action('onThemePreferenceChange') const { add } = storiesOf('web/UserNavItem', module) -const OpenUserNavItem: React.FunctionComponent = props => { - const openDropdown = useCallback((userNavItem: UserNavItem | null) => { - if (userNavItem) { - userNavItem.setState({ isOpen: true }) - } - }, []) - return -} - add( 'Site admin', () => ( {webProps => ( - )} diff --git a/client/web/src/nav/UserNavItem.test.tsx b/client/web/src/nav/UserNavItem.test.tsx index f58c0d806ce1..52bbbc4062fd 100644 --- a/client/web/src/nav/UserNavItem.test.tsx +++ b/client/web/src/nav/UserNavItem.test.tsx @@ -45,6 +45,7 @@ describe('UserNavItem', () => { location={history.location} authenticatedUser={USER} showDotComMarketing={true} + isExtensionAlertAnimating={false} /> ) diff --git a/client/web/src/nav/UserNavItem.tsx b/client/web/src/nav/UserNavItem.tsx index 3ecda67b12b4..8b52fa39b160 100644 --- a/client/web/src/nav/UserNavItem.tsx +++ b/client/web/src/nav/UserNavItem.tsx @@ -1,16 +1,17 @@ import { Shortcut } from '@slimsag/react-shortcuts' import * as H from 'history' -import React from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' -import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap' +import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle, Tooltip } from 'reactstrap' import { KeyboardShortcut } from '../../../shared/src/keyboardShortcuts' import { ThemeProps } from '../../../shared/src/theme' import { UserAvatar } from '../user/UserAvatar' import { ThemePreferenceProps, ThemePreference } from '../theme' import { AuthenticatedUser } from '../auth' import OpenInNewIcon from 'mdi-react/OpenInNewIcon' - -export interface UserNavItemProps extends ThemeProps, ThemePreferenceProps { +import { useTimeoutManager } from '../../../shared/src/util/useTimeoutManager' +import classNames from 'classnames' +export interface UserNavItemProps extends ThemeProps, ThemePreferenceProps, ExtensionAlertAnimationProps { location: H.Location authenticatedUser: Pick< AuthenticatedUser, @@ -18,149 +19,193 @@ export interface UserNavItemProps extends ThemeProps, ThemePreferenceProps { > showDotComMarketing: boolean keyboardShortcutForSwitchTheme?: KeyboardShortcut + testIsOpen?: boolean +} + +export interface ExtensionAlertAnimationProps { + isExtensionAlertAnimating: boolean } -interface State { - isOpen: boolean +/** + * React hook to manage the animation that occurs after the user dismisses + * `InstallBrowserExtensionAlert`. + * + * This hook is called from the the LCA of `UserNavItem` and the component that triggers + * the animation. + */ +export function useExtensionAlertAnimation(): ExtensionAlertAnimationProps & { + startExtensionAlertAnimation: () => void +} { + const [isAnimating, setIsAnimating] = useState(false) + + const animationManager = useTimeoutManager() + + const startExtensionAlertAnimation = useCallback(() => { + if (!isAnimating) { + setIsAnimating(true) + + animationManager.setTimeout(() => { + setIsAnimating(false) + }, 5100) + } + }, [isAnimating, animationManager]) + + return { isExtensionAlertAnimating: isAnimating, startExtensionAlertAnimation } } /** * Displays the user's avatar and/or username in the navbar and exposes a dropdown menu with more options for * authenticated viewers. */ -export class UserNavItem extends React.PureComponent { - private supportsSystemTheme = Boolean( - window.matchMedia?.('not all and (prefers-color-scheme), (prefers-color-scheme)').matches +export const UserNavItem: React.FunctionComponent = props => { + const { location, themePreference, onThemePreferenceChange, isExtensionAlertAnimating, testIsOpen } = props + + const supportsSystemTheme = useMemo( + () => Boolean(window.matchMedia?.('not all and (prefers-color-scheme), (prefers-color-scheme)').matches), + [] ) - public state: State = { isOpen: false } + const [isOpen, setIsOpen] = useState(() => !!testIsOpen) + const toggleIsOpen = useCallback(() => setIsOpen(open => !open), []) - public componentDidUpdate(previousProps: UserNavItemProps): void { + useEffect(() => { // Close dropdown after clicking on a dropdown item. - if (this.state.isOpen && this.props.location !== previousProps.location) { - /* eslint react/no-did-update-set-state: warn */ - this.setState({ isOpen: false }) + if (!testIsOpen) { + setIsOpen(false) } - } + }, [location.pathname, testIsOpen]) - public render(): JSX.Element | null { - return ( - - - {this.props.authenticatedUser.avatarURL ? ( - - ) : ( - {this.props.authenticatedUser.username} - )} - - - - Signed in as @{this.props.authenticatedUser.username} - - - - Settings - - - Extensions - - - Saved searches - - -
-
-
Theme
- -
- {this.props.themePreference === ThemePreference.System && !this.supportsSystemTheme && ( -
- )} - {this.props.keyboardShortcutForSwitchTheme?.keybindings.map((keybinding, index) => ( - - ))} + const onThemeChange: React.ChangeEventHandler = useCallback( + event => { + onThemePreferenceChange(event.target.value as ThemePreference) + }, + [onThemePreferenceChange] + ) + + const onThemeCycle = useCallback((): void => { + onThemePreferenceChange(themePreference === ThemePreference.Dark ? ThemePreference.Light : ThemePreference.Dark) + }, [onThemePreferenceChange, themePreference]) + + // Target ID for tooltip + const targetID = 'target-user-avatar' + + return ( + + +
+
+ +
+ {isExtensionAlertAnimating && ( + + Install the browser extension from here later + + )} + + + + Signed in as @{props.authenticatedUser.username} + + + + Settings + + + Extensions + + + Saved searches + + +
+
+
Theme
+
- {this.props.authenticatedUser.organizations.nodes.length > 0 && ( - <> - - Organizations - {this.props.authenticatedUser.organizations.nodes.map(org => ( - - {org.displayName || org.name} - - ))} - - )} - - {this.props.authenticatedUser.siteAdmin && ( - - Site admin - + {props.themePreference === ThemePreference.System && !supportsSystemTheme && ( + )} - - Help + {props.keyboardShortcutForSwitchTheme?.keybindings.map((keybinding, index) => ( + + ))} +
+ {props.authenticatedUser.organizations.nodes.length > 0 && ( + <> + + Organizations + {props.authenticatedUser.organizations.nodes.map(org => ( + + {org.displayName || org.name} + + ))} + + )} + + {props.authenticatedUser.siteAdmin && ( + + Site admin - {this.props.authenticatedUser.session?.canSignOut && ( - - Sign out - - )} - - {this.props.showDotComMarketing && ( - - About Sourcegraph - - )} - - Browser extension + )} + + Help + + {props.authenticatedUser.session?.canSignOut && ( + + Sign out -
- - ) - } - - private toggleIsOpen = (): void => this.setState(previousState => ({ isOpen: !previousState.isOpen })) - - private onThemeChange: React.ChangeEventHandler = event => { - this.props.onThemePreferenceChange(event.target.value as ThemePreference) - } - - private onThemeCycle = (): void => { - this.props.onThemePreferenceChange( - this.props.themePreference === ThemePreference.Dark ? ThemePreference.Light : ThemePreference.Dark - ) - } + )} + + {props.showDotComMarketing && ( + + About Sourcegraph + + )} + + Browser extension + + + + ) } diff --git a/client/web/src/nav/__snapshots__/GlobalNavbar.test.tsx.snap b/client/web/src/nav/__snapshots__/GlobalNavbar.test.tsx.snap index 88c2e518c34d..cc25fb51cf58 100644 --- a/client/web/src/nav/__snapshots__/GlobalNavbar.test.tsx.snap +++ b/client/web/src/nav/__snapshots__/GlobalNavbar.test.tsx.snap @@ -56,6 +56,7 @@ exports[`GlobalNavbar default 1`] = ` "replace": [Function], } } + isExtensionAlertAnimating={false} isLightTheme={true} isSourcegraphDotCom={false} keyboardShortcuts={Array []} diff --git a/client/web/src/nav/__snapshots__/NavLinks.test.tsx.snap b/client/web/src/nav/__snapshots__/NavLinks.test.tsx.snap index b6f12f61f94c..d1e995cd20f9 100644 --- a/client/web/src/nav/__snapshots__/NavLinks.test.tsx.snap +++ b/client/web/src/nav/__snapshots__/NavLinks.test.tsx.snap @@ -203,9 +203,25 @@ exports[`NavLinks authed Sourcegraph.com /foo 1`] = ` class="bg-transparent d-flex align-items-center test-user-nav-item-toggle dropdown-toggle nav-link" href="#" > - - alice - +
+
+ + + +