summaryrefslogtreecommitdiffhomepage
path: root/gui
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-07-01 13:39:36 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-07-01 13:39:36 +0200
commit8cdfac15f26348da9b164716f1cad3c5287b3a80 (patch)
treea600520c5b7d58244a165982178f5c7cb7293da8 /gui
parent695a307f5717ea3df9ecf9b4e71422cc52090137 (diff)
parent830c390f2bc6cb73efe961afb9dee2cf54c02eb9 (diff)
downloadmullvadvpn-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.tsx2
-rw-r--r--gui/src/renderer/components/Connect.tsx17
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx11
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx393
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>
+ );
}