diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-07-01 13:39:36 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-07-01 13:39:36 +0200 |
| commit | 8cdfac15f26348da9b164716f1cad3c5287b3a80 (patch) | |
| tree | a600520c5b7d58244a165982178f5c7cb7293da8 /gui | |
| parent | 695a307f5717ea3df9ecf9b4e71422cc52090137 (diff) | |
| parent | 830c390f2bc6cb73efe961afb9dee2cf54c02eb9 (diff) | |
| download | mullvadvpn-8cdfac15f26348da9b164716f1cad3c5287b3a80.tar.xz mullvadvpn-8cdfac15f26348da9b164716f1cad3c5287b3a80.zip | |
Merge branch 'convert-notification-area-from-reactxp'
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/src/renderer/components/AppButton.tsx | 2 | ||||
| -rw-r--r-- | gui/src/renderer/components/Connect.tsx | 17 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationArea.tsx | 11 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationBanner.tsx | 393 |
4 files changed, 145 insertions, 278 deletions
diff --git a/gui/src/renderer/components/AppButton.tsx b/gui/src/renderer/components/AppButton.tsx index b5ad906410..c0731917d0 100644 --- a/gui/src/renderer/components/AppButton.tsx +++ b/gui/src/renderer/components/AppButton.tsx @@ -133,8 +133,6 @@ export class BlockingButton extends React.Component<IBlockingProps, IBlockingSta ...child.props, disabled: this.state.isBlocked || this.props.disabled, onClick: this.onClick, - // TODO: Remove when NotificationOpenLinkAction has been converted from ReactXP - onPress: this.onClick, }); } else { return child; diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx index bc5d854bf1..0db4a40141 100644 --- a/gui/src/renderer/components/Connect.tsx +++ b/gui/src/renderer/components/Connect.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { Styles } from 'reactxp'; import styled from 'styled-components'; import { hasExpired } from '../../shared/account-expiry'; import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer'; @@ -55,14 +54,12 @@ const StatusIcon = styled(ImageView)({ marginTop: 94, }); -const styles = { - notificationArea: Styles.createViewStyle({ - position: 'absolute', - left: 0, - top: 0, - right: 0, - }), -}; +const StyledNotificationArea = styled(NotificationArea)({ + position: 'absolute', + left: 0, + top: 0, + right: 0, +}); interface IState { isAccountExpired: boolean; @@ -157,7 +154,7 @@ export default class Connect extends React.Component<IProps, IState> { onSelectLocation={this.props.onSelectLocation} /> - <NotificationArea style={styles.notificationArea} /> + <StyledNotificationArea /> </Content> </> ); diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index a6d4699e6a..8cbee0df0f 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -2,7 +2,6 @@ import { shell } from 'electron'; import log from 'electron-log'; import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { Types } from 'reactxp'; import { CloseToAccountExpiryNotificationProvider, BlockWhenDisconnectedNotificationProvider, @@ -28,7 +27,7 @@ import { } from './NotificationBanner'; interface IProps { - style?: Types.ViewStyleRuleSet; + className?: string; } export default function NotificationArea(props: IProps) { @@ -65,7 +64,7 @@ export default function NotificationArea(props: IProps) { if (notification) { return ( - <NotificationBanner style={props.style} visible> + <NotificationBanner className={props.className} visible> <NotificationIndicator type={notification.indicator} /> <NotificationContent> <NotificationTitle>{notification.title}</NotificationTitle> @@ -81,7 +80,7 @@ export default function NotificationArea(props: IProps) { } } - return <NotificationBanner style={props.style} visible={false} />; + return <NotificationBanner className={props.className} visible={false} />; } interface INotificationActionWrapperProps { @@ -91,7 +90,7 @@ interface INotificationActionWrapperProps { function NotificationActionWrapper(props: INotificationActionWrapperProps) { const { openLinkWithAuth } = useAppContext(); - const handlePress = useCallback(() => { + const handleClick = useCallback(() => { if (props.action.withAuth) { return openLinkWithAuth(props.action.url); } else { @@ -101,7 +100,7 @@ function NotificationActionWrapper(props: INotificationActionWrapperProps) { return ( <NotificationActions> - <NotificationOpenLinkAction onPress={handlePress} /> + <NotificationOpenLinkAction onClick={handleClick} /> </NotificationActions> ); } diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx index 207c3f6081..a4ee59f9ce 100644 --- a/gui/src/renderer/components/NotificationBanner.tsx +++ b/gui/src/renderer/components/NotificationBanner.tsx @@ -1,302 +1,175 @@ -import * as React from 'react'; -import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; import { colors } from '../../config.json'; import { InAppNotificationIndicatorType } from '../../shared/notifications/notification'; -import consumePromise from '../../shared/promise'; import { BlockingButton } from './AppButton'; import ImageView from './ImageView'; -const styles = { - collapsible: Styles.createViewStyle({ - backgroundColor: 'rgba(25, 38, 56, 0.95)', - overflow: 'hidden', - }), - drawer: Styles.createViewStyle({ - justifyContent: 'flex-end', - }), - container: Styles.createViewStyle({ - flexDirection: 'row', - paddingTop: 8, - paddingLeft: 20, - paddingRight: 10, - paddingBottom: 8, - }), - indicator: { - base: Styles.createViewStyle({ - width: 10, - height: 10, - flex: 0, - borderRadius: 5, - marginTop: 4, - marginRight: 8, - }), - warning: Styles.createViewStyle({ - backgroundColor: colors.yellow, - }), - success: Styles.createViewStyle({ - backgroundColor: colors.green, - }), - error: Styles.createViewStyle({ - backgroundColor: colors.red, - }), - }, - textContainer: Styles.createViewStyle({ - flex: 1, - }), - actionContainer: Styles.createViewStyle({ - flex: 0, - flexDirection: 'column', - justifyContent: 'center', - marginLeft: 5, - }), - actionButton: Styles.createButtonStyle({ - flex: 1, - justifyContent: 'center', - cursor: 'default', - paddingLeft: 5, - paddingRight: 5, - }), - title: Styles.createTextStyle({ - fontFamily: 'Open Sans', - fontSize: 13, - fontWeight: '800', - lineHeight: 18, - color: colors.white, - }), - subtitle: Styles.createTextStyle({ - fontFamily: 'Open Sans', - fontSize: 13, - fontWeight: '600', - lineHeight: 18, - color: colors.white60, - }), -}; - -interface INotificationTitleProps { - children?: React.ReactNode; -} +export const NotificationTitle = styled.span({ + fontFamily: 'Open Sans', + fontSize: '13px', + fontWeight: 800, + lineHeight: '18px', + color: colors.white, +}); -export class NotificationTitle extends Component<INotificationTitleProps> { - public render() { - return <Text style={styles.title}>{this.props.children}</Text>; - } -} +export const NotificationSubtitleText = styled.span({ + fontFamily: 'Open Sans', + fontSize: '13px', + fontWeight: 600, + lineHeight: '18px', + color: colors.white60, +}); interface INotificationSubtitleProps { children?: React.ReactNode; } -export class NotificationSubtitle extends Component<INotificationSubtitleProps> { - public render() { - return React.Children.count(this.props.children) > 0 ? ( - <Text style={styles.subtitle}>{this.props.children}</Text> - ) : null; - } +export function NotificationSubtitle(props: INotificationSubtitleProps) { + return React.Children.count(props.children) > 0 ? <NotificationSubtitleText {...props} /> : null; } -interface INotifcationOpenLinkActionProps { - onPress: () => Promise<void>; - children?: React.ReactNode; -} - -export class NotificationOpenLinkAction extends Component<INotifcationOpenLinkActionProps> { - public state = { - hovered: false, - }; +export const NotificationOpenLinkActionButton = styled.button({ + flex: 1, + justifyContent: 'center', + cursor: 'default', + padding: '0 5px', + background: 'transparent', + border: 'none', +}); - public render() { - return ( - <BlockingButton onClick={this.props.onPress}> - <Button - style={styles.actionButton} - onHoverStart={this.onHoverStart} - onHoverEnd={this.onHoverEnd}> - <ImageView - height={12} - width={12} - tintColor={this.state.hovered ? colors.white80 : colors.white60} - source="icon-extLink" - /> - </Button> - </BlockingButton> - ); - } - - private onHoverStart = () => { - this.setState({ hovered: true }); - }; - - private onHoverEnd = () => { - this.setState({ hovered: false }); - }; -} +export const NotificationOpenLinkActionIcon = styled(ImageView)({ + [NotificationOpenLinkActionButton + ':hover &']: { + backgroundColor: colors.white80, + }, +}); -interface INotificationContentProps { +interface INotifcationOpenLinkActionProps { + onClick: () => Promise<void>; children?: React.ReactNode; } -export class NotificationContent extends Component<INotificationContentProps> { - public render() { - return <View style={styles.textContainer}>{this.props.children}</View>; - } +export function NotificationOpenLinkAction(props: INotifcationOpenLinkActionProps) { + return ( + <BlockingButton onClick={props.onClick}> + <NotificationOpenLinkActionButton> + <NotificationOpenLinkActionIcon + height={12} + width={12} + tintColor={colors.white60} + source="icon-extLink" + /> + </NotificationOpenLinkActionButton> + </BlockingButton> + ); } -interface INotificationActionsProps { - children?: React.ReactNode; -} +export const NotificationContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, +}); -export class NotificationActions extends Component<INotificationActionsProps> { - public render() { - return <View style={styles.actionContainer}>{this.props.children}</View>; - } -} +export const NotificationActions = styled.div({ + display: 'flex', + flex: 0, + flexDirection: 'column', + justifyContent: 'center', + marginLeft: 5, +}); interface INotificationIndicatorProps { type: InAppNotificationIndicatorType; - children?: React.ReactNode; } -export class NotificationIndicator extends Component<INotificationIndicatorProps> { - public render() { - return <View style={[styles.indicator.base, styles.indicator[this.props.type]]} />; - } -} +const notificationIndicatorTypeColorMap = { + success: colors.green, + warning: colors.yellow, + error: colors.red, +}; -interface INotificationBannerProps { - children?: React.ReactNode; // Array<NotificationContent | NotificationActions>, - style?: Types.ViewStyleRuleSet; - visible: boolean; - animationDuration: number; -} +export const NotificationIndicator = styled.div((props: INotificationIndicatorProps) => ({ + width: '10px', + height: '10px', + borderRadius: '5px', + marginTop: '4px', + marginRight: '8px', + backgroundColor: notificationIndicatorTypeColorMap[props.type], +})); -interface INotificationBannerState { - contentPinnedToBottom: boolean; +interface ICollapsibleProps { + alignBottom: boolean; + contentHeight?: number; + collapsibleHeight?: number; } -export class NotificationBanner extends Component< - INotificationBannerProps, - INotificationBannerState -> { - public static defaultProps = { - animationDuration: 350, - }; - - public state = { - contentPinnedToBottom: false, - }; - - private containerRef = React.createRef<Animated.View>(); - private contentHeight = 0; - private heightValue = Animated.createValue(0); - private animationStyle: Types.AnimatedViewStyleRuleSet; - private animation?: Types.Animated.CompositeAnimation; - private didFinishFirstLayoutPass = false; - - constructor(props: INotificationBannerProps) { - super(props); - - this.animationStyle = Styles.createAnimatedViewStyle({ - height: this.heightValue, - }); - } - - public shouldComponentUpdate( - nextProps: INotificationBannerProps, - nextState: INotificationBannerState, - ) { - return ( - this.props.children !== nextProps.children || - this.props.visible !== nextProps.visible || - this.state.contentPinnedToBottom !== nextState.contentPinnedToBottom - ); - } - - public componentDidUpdate(prevProps: INotificationBannerProps) { - if (prevProps.visible !== this.props.visible) { - // enable drawer-like animation when changing banner's visibility - this.setState({ contentPinnedToBottom: true }, () => { - consumePromise(this.animateHeightChanges()); - }); - } - } - - public componentWillUnmount() { - if (this.animation) { - this.animation.stop(); - } - } - - public render() { - return ( - <Animated.View - style={[ - styles.collapsible, - this.state.contentPinnedToBottom ? styles.drawer : undefined, - this.animationStyle, - this.props.style, - ]} - ref={this.containerRef}> - <View onLayout={this.onLayout}> - <View style={styles.container}>{this.props.children}</View> - </View> - </Animated.View> - ); - } +const TRANSITION_DURATION = 350; +// 52px is the height of the banner when the notification contains a title and subtitle which are +// one line each. +const TRANSITION_BASE_DISTANCE = 52; - private onLayout = ({ height }: Types.ViewOnLayoutEvent) => { - const oldHeight = this.contentHeight; - this.contentHeight = height; +const Collapsible = styled.div({}, (props: ICollapsibleProps) => { + // Calculate the transition duration based on travel distance. + const distance = Math.abs((props.collapsibleHeight ?? 0) - (props.contentHeight ?? 0)); + const duration = Math.ceil(TRANSITION_DURATION * (distance / TRANSITION_BASE_DISTANCE)); - // The first layout pass should not be animated because this would cause the initially visible - // notification banner to slide down each time the component is mounted. - if (this.didFinishFirstLayoutPass) { - if (oldHeight !== height) { - consumePromise(this.animateHeightChanges()); - } - } else { - this.didFinishFirstLayoutPass = true; - if (this.props.visible) { - this.stopAnimation(); - this.heightValue.setValue(height); - } - } + return { + display: 'flex', + flexDirection: 'column', + justifyContent: props.alignBottom ? 'flex-end' : 'flex-start', + backgroundColor: 'rgba(25, 38, 56, 0.95)', + overflow: 'hidden', + // Using auto as the initial value prevents transition if a notification is visible on mount. + height: props.contentHeight === undefined ? 'auto' : `${props.contentHeight}px`, + transition: `height ${duration}ms ease-in-out`, }; +}); - private async animateHeightChanges() { - const containerView = this.containerRef.current; - if (!containerView) { - return; - } +const Content = styled.div({ + display: 'flex', + flexDirection: 'row', + padding: '8px 10px 8px 20px', + height: 'fit-content', +}); - this.stopAnimation(); +interface INotificationBannerProps { + children?: React.ReactNode; // Array<NotificationContent | NotificationActions>, + className?: string; + visible: boolean; +} - // calculate the animation duration based on travel distance - const layout = await UserInterface.measureLayoutRelativeToWindow(containerView); - const toValue = this.props.visible ? this.contentHeight : 0; - const multiplier = Math.abs(toValue - layout.height) / Math.max(1, this.contentHeight); - const duration = Math.ceil(this.props.animationDuration * multiplier); +export function NotificationBanner(props: INotificationBannerProps) { + const [contentHeight, setContentHeight] = useState<number>(); + const [alignBottom, setAlignBottom] = useState(false); - const animation = Animated.timing(this.heightValue, { - toValue, - easing: Animated.Easing.InOut(), - duration, - useNativeDriver: true, - }); + const contentRef = useRef() as React.RefObject<HTMLDivElement>; + const collapsibleRef = useRef() as React.RefObject<HTMLDivElement>; - this.animation = animation; + // Save last non-undefined children to be able to show them during the hide-transition. + const prevChildren = useRef<React.ReactNode>(); + useEffect(() => { + prevChildren.current = props.children ?? prevChildren.current; + }, [props.children]); - animation.start(({ finished }) => { - if (finished) { - // disable drawer-like animations for content updates when the banner is visible - this.setState({ contentPinnedToBottom: false }); - } - }); - } + const onTransitionEnd = useCallback(() => setAlignBottom(false), []); - private stopAnimation() { - if (this.animation) { - this.animation.stop(); - this.animation = undefined; + useLayoutEffect(() => { + const newHeight = props.visible ? contentRef.current?.getBoundingClientRect().height ?? 0 : 0; + if (newHeight !== contentHeight) { + setContentHeight(newHeight); + setAlignBottom((alignBottom) => alignBottom || contentHeight === 0 || newHeight === 0); } - } + }); + + return ( + <Collapsible + ref={collapsibleRef} + alignBottom={alignBottom} + contentHeight={contentHeight} + collapsibleHeight={collapsibleRef.current?.getBoundingClientRect().height ?? 0} + className={props.className} + onTransitionEnd={onTransitionEnd}> + <Content ref={contentRef}>{props.visible ? props.children : prevChildren.current}</Content> + </Collapsible> + ); } |
