From 69642544acfa94ab425ab164fcf593cd773007a3 Mon Sep 17 00:00:00 2001
From: Kerry <kerrya@element.io>
Date: Wed, 26 Oct 2022 11:04:16 +0200
Subject: [PATCH] Device manager - add learn more popups to filtered sessions
 section (#9497)

* add learn more to filtered sessions

* fullstop

* update tests and i18n for fullstop

* remove unused switch

* whitespace

* use correct card type
---
 res/css/_components.pcss                      |   7 +-
 .../components/views/elements/_LearnMore.pcss |  19 +++
 .../views/elements/AccessibleButton.tsx       |   2 +-
 src/components/views/elements/LearnMore.tsx   |  56 ++++++++
 .../settings/devices/FilteredDeviceList.tsx   | 121 ++++++++++++------
 src/i18n/strings/en_EN.json                   |   9 +-
 .../views/elements/LearnMore-test.tsx         |  57 +++++++++
 .../__snapshots__/LearnMore-test.tsx.snap     |  14 ++
 .../FilteredDeviceList-test.tsx.snap          |  33 ++++-
 9 files changed, 270 insertions(+), 48 deletions(-)
 create mode 100644 res/css/components/views/elements/_LearnMore.pcss
 create mode 100644 src/components/views/elements/LearnMore.tsx
 create mode 100644 test/components/views/elements/LearnMore-test.tsx
 create mode 100644 test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap

diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index 4417382b20..9179085cab 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -4,7 +4,6 @@
 @import "./_font-sizes.pcss";
 @import "./_font-weights.pcss";
 @import "./_spacing.pcss";
-@import "./compound/_Icon.pcss";
 @import "./components/views/beacon/_BeaconListItem.pcss";
 @import "./components/views/beacon/_BeaconStatus.pcss";
 @import "./components/views/beacon/_BeaconStatusTooltip.pcss";
@@ -19,6 +18,7 @@
 @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
 @import "./components/views/context_menus/_KebabContextMenu.pcss";
 @import "./components/views/elements/_FilterDropdown.pcss";
+@import "./components/views/elements/_LearnMore.pcss";
 @import "./components/views/location/_EnableLiveShare.pcss";
 @import "./components/views/location/_LiveDurationDropdown.pcss";
 @import "./components/views/location/_LocationShareMenu.pcss";
@@ -44,6 +44,7 @@
 @import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
 @import "./components/views/spaces/_QuickThemeSwitcher.pcss";
 @import "./components/views/typography/_Caption.pcss";
+@import "./compound/_Icon.pcss";
 @import "./structures/_AutoHideScrollbar.pcss";
 @import "./structures/_BackdropPanel.pcss";
 @import "./structures/_CompatibilityPage.pcss";
@@ -299,10 +300,10 @@
 @import "./views/rooms/_TopUnreadMessagesBar.pcss";
 @import "./views/rooms/_VoiceRecordComposerTile.pcss";
 @import "./views/rooms/_WhoIsTypingTile.pcss";
+@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
+@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss";
 @import "./views/rooms/wysiwyg_composer/components/_Editor.pcss";
 @import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
-@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss";
-@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
 @import "./views/settings/_AvatarSetting.pcss";
 @import "./views/settings/_CrossSigningPanel.pcss";
 @import "./views/settings/_CryptographyPanel.pcss";
diff --git a/res/css/components/views/elements/_LearnMore.pcss b/res/css/components/views/elements/_LearnMore.pcss
new file mode 100644
index 0000000000..97f3b4c527
--- /dev/null
+++ b/res/css/components/views/elements/_LearnMore.pcss
@@ -0,0 +1,19 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_LearnMore_button {
+    margin-left: $spacing-4;
+}
diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx
index 7036575cd1..6f11fa12bd 100644
--- a/src/components/views/elements/AccessibleButton.tsx
+++ b/src/components/views/elements/AccessibleButton.tsx
@@ -75,7 +75,7 @@ type IProps<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T>
     onClick: ((e: ButtonEvent) => void | Promise<void>) | null;
 };
 
-interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
+export interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
     ref?: React.Ref<Element>;
 }
 
diff --git a/src/components/views/elements/LearnMore.tsx b/src/components/views/elements/LearnMore.tsx
new file mode 100644
index 0000000000..1a96e3d8f4
--- /dev/null
+++ b/src/components/views/elements/LearnMore.tsx
@@ -0,0 +1,56 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+
+import { _t } from '../../../languageHandler';
+import Modal from '../../../Modal';
+import InfoDialog from '../dialogs/InfoDialog';
+import AccessibleButton, { IAccessibleButtonProps } from './AccessibleButton';
+
+interface Props extends IAccessibleButtonProps {
+    title: string;
+    description: string | React.ReactNode;
+}
+
+const LearnMore: React.FC<Props> = ({
+    title,
+    description,
+    ...rest
+}) => {
+    const onClick = () => {
+        Modal.createDialog(
+            InfoDialog,
+            {
+                title,
+                description,
+                button: _t('Got it'),
+                hasCloseButton: true,
+            },
+        );
+    };
+
+    return <AccessibleButton
+        {...rest}
+        kind='link_inline'
+        onClick={onClick}
+        className='mx_LearnMore_button'
+    >
+        { _t('Learn more') }
+    </AccessibleButton>;
+};
+
+export default LearnMore;
diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx
index 9bc216a086..a2afcc22f6 100644
--- a/src/components/views/settings/devices/FilteredDeviceList.tsx
+++ b/src/components/views/settings/devices/FilteredDeviceList.tsx
@@ -38,6 +38,7 @@ import {
 import { DevicesState } from './useOwnDevices';
 import FilteredDeviceListHeader from './FilteredDeviceListHeader';
 import Spinner from '../../elements/Spinner';
+import LearnMore from '../../elements/LearnMore';
 
 interface Props {
     devices: DevicesDictionary;
@@ -73,48 +74,88 @@ const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSec
 const ALL_FILTER_ID = 'ALL';
 type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID;
 
+const securityCardContent: Record<DeviceSecurityVariation, {
+    title: string;
+    description: string;
+    learnMoreDescription: React.ReactNode | string;
+ }> = {
+     [DeviceSecurityVariation.Verified]: {
+         title: _t('Verified sessions'),
+         description: _t('For best security, sign out from any session that you don\'t recognize or use anymore.'),
+         learnMoreDescription: <>
+             <p>{ _t('Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.') }
+             </p>
+             <p>
+                 { _t(
+                     `This means they hold encryption keys for your previous messages, ` +
+                        `and confirm to other users you are communicating with that these sessions are really you.`,
+                 )
+                 }
+             </p>
+         </>,
+     },
+     [DeviceSecurityVariation.Unverified]: {
+         title: _t('Unverified sessions'),
+         description: _t(
+             `Verify your sessions for enhanced secure messaging or ` +
+            `sign out from those you don't recognize or use anymore.`,
+         ),
+         learnMoreDescription: <>
+             <p>{ _t('Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.') }
+             </p>
+             <p>
+                 { _t(
+                     `You should make especially certain that you recognise these sessions ` +
+                    `as they could represent an unauthorised use of your account.`,
+                 )
+                 }
+             </p>
+         </>,
+     },
+     [DeviceSecurityVariation.Inactive]: {
+         title: _t('Inactive sessions'),
+         description: _t(
+             `Consider signing out from old sessions ` +
+        `(%(inactiveAgeDays)s days or older) you don't use anymore.`,
+             { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
+         ),
+         learnMoreDescription: <>
+             <p>{ _t('Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.') }
+             </p>
+             <p>
+                 { _t(
+                     `Removing inactive sessions improves security and performance, ` +
+                    `and makes it easier for you to identify if a new session is suspicious.`,
+                 )
+                 }
+             </p>
+         </>,
+     },
+ };
+
+const isSecurityVariation = (filter?: DeviceFilterKey): filter is DeviceSecurityVariation =>
+    Object.values<string>(DeviceSecurityVariation).includes(filter);
+
 const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
-    switch (filter) {
-        case DeviceSecurityVariation.Verified:
-            return <div className='mx_FilteredDeviceList_securityCard'>
-                <DeviceSecurityCard
-                    variation={DeviceSecurityVariation.Verified}
-                    heading={_t('Verified sessions')}
-                    description={_t(
-                        `For best security, sign out from any session` +
-                    ` that you don't recognize or use anymore.`,
-                    )}
-                />
-            </div>
-            ;
-        case DeviceSecurityVariation.Unverified:
-            return <div className='mx_FilteredDeviceList_securityCard'>
-                <DeviceSecurityCard
-                    variation={DeviceSecurityVariation.Unverified}
-                    heading={_t('Unverified sessions')}
-                    description={_t(
-                        `Verify your sessions for enhanced secure messaging or sign out`
-                    + ` from those you don't recognize or use anymore.`,
-                    )}
-                />
-            </div>
-            ;
-        case DeviceSecurityVariation.Inactive:
-            return <div className='mx_FilteredDeviceList_securityCard'>
-                <DeviceSecurityCard
-                    variation={DeviceSecurityVariation.Inactive}
-                    heading={_t('Inactive sessions')}
-                    description={_t(
-                        `Consider signing out from old sessions ` +
-                    `(%(inactiveAgeDays)s days or older) you don't use anymore`,
-                        { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
-                    )}
-                />
-            </div>
-            ;
-        default:
-            return null;
+    if (isSecurityVariation(filter)) {
+        const { title, description, learnMoreDescription } = securityCardContent[filter];
+        return <div className='mx_FilteredDeviceList_securityCard'>
+            <DeviceSecurityCard
+                variation={filter}
+                heading={title}
+                description={<span>
+                    { description }
+                    <LearnMore
+                        title={title}
+                        description={learnMoreDescription}
+                    />
+                </span>}
+            />
+        </div>
+        ;
     }
+
+    return null;
 };
 
 const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index a33de8ea15..8af41255fc 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1778,10 +1778,16 @@
     "Verify session": "Verify session",
     "Verified sessions": "Verified sessions",
     "For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",
+    "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.": "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.",
+    "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.": "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.",
     "Unverified sessions": "Unverified sessions",
     "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.",
+    "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.",
+    "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.",
     "Inactive sessions": "Inactive sessions",
-    "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
+    "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.",
+    "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.",
+    "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.",
     "No verified sessions found.": "No verified sessions found.",
     "No unverified sessions found.": "No unverified sessions found.",
     "No inactive sessions found.": "No inactive sessions found.",
@@ -1801,6 +1807,7 @@
     "Security recommendations": "Security recommendations",
     "Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
     "View all": "View all",
+    "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
     "Failed to set pusher state": "Failed to set pusher state",
     "Unable to remove contact information": "Unable to remove contact information",
     "Remove %(email)s?": "Remove %(email)s?",
diff --git a/test/components/views/elements/LearnMore-test.tsx b/test/components/views/elements/LearnMore-test.tsx
new file mode 100644
index 0000000000..6ae577543c
--- /dev/null
+++ b/test/components/views/elements/LearnMore-test.tsx
@@ -0,0 +1,57 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+
+import LearnMore from '../../../../src/components/views/elements/LearnMore';
+import Modal from '../../../../src/Modal';
+import InfoDialog from '../../../../src/components/views/dialogs/InfoDialog';
+
+describe('<LearnMore />', () => {
+    const defaultProps = {
+        title: 'Test',
+        description: 'test test test',
+        ['data-testid']: 'testid',
+    };
+    const getComponent = (props = {}) =>
+        (<LearnMore {...defaultProps} {...props} />);
+
+    const modalSpy = jest.spyOn(Modal, 'createDialog').mockReturnValue(undefined);
+
+    beforeEach(() => {
+        jest.clearAllMocks();
+    });
+
+    it('renders button', () => {
+        const { container } = render(getComponent());
+        expect(container).toMatchSnapshot();
+    });
+
+    it('opens modal on click', async () => {
+        const { getByTestId } = render(getComponent());
+        fireEvent.click(getByTestId('testid'));
+
+        expect(modalSpy).toHaveBeenCalledWith(
+            InfoDialog,
+            {
+                button: 'Got it',
+                description: defaultProps.description,
+                hasCloseButton: true,
+                title: defaultProps.title,
+            });
+    });
+});
diff --git a/test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap b/test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap
new file mode 100644
index 0000000000..41904877c8
--- /dev/null
+++ b/test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<LearnMore /> renders button 1`] = `
+<div>
+  <div
+    class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
+    data-testid="testid"
+    role="button"
+    tabindex="0"
+  >
+    Learn more
+  </div>
+</div>
+`;
diff --git a/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap
index c0f5b9af98..62a6cd94d1 100644
--- a/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap
+++ b/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap
@@ -37,7 +37,16 @@ HTMLCollection [
         <p
           class="mx_DeviceSecurityCard_description"
         >
-          Consider signing out from old sessions (90 days or older) you don't use anymore
+          <span>
+            Consider signing out from old sessions (90 days or older) you don't use anymore.
+            <div
+              class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
+              role="button"
+              tabindex="0"
+            >
+              Learn more
+            </div>
+          </span>
         </p>
       </div>
     </div>
@@ -72,7 +81,16 @@ HTMLCollection [
         <p
           class="mx_DeviceSecurityCard_description"
         >
-          Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+          <span>
+            Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+            <div
+              class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
+              role="button"
+              tabindex="0"
+            >
+              Learn more
+            </div>
+          </span>
         </p>
       </div>
     </div>
@@ -107,7 +125,16 @@ HTMLCollection [
         <p
           class="mx_DeviceSecurityCard_description"
         >
-          For best security, sign out from any session that you don't recognize or use anymore.
+          <span>
+            For best security, sign out from any session that you don't recognize or use anymore.
+            <div
+              class="mx_AccessibleButton mx_LearnMore_button mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link_inline"
+              role="button"
+              tabindex="0"
+            >
+              Learn more
+            </div>
+          </span>
         </p>
       </div>
     </div>