From 94f3168ab8089024ef34e99230763d44a0370e2f Mon Sep 17 00:00:00 2001
From: Kerry <kerrya@element.io>
Date: Mon, 8 Aug 2022 08:59:22 +0200
Subject: [PATCH] Device manager - New device tile info design (#9122)(PSG-637)

* redesign device tile info

* test DeviceTile except for broken date mocking

* mock dates the nice way, test lastactivity in device tile

* tweak spacing style

* update comment style in rethemendex

* i18n
---
 res/css/_components.pcss                      |   1 +
 .../views/settings/devices/_DeviceTile.pcss   |  41 +++++++
 res/css/rethemendex.sh                        |   2 +-
 .../views/settings/DevicesPanelEntry.tsx      |  36 +-----
 .../views/settings/devices/DeviceTile.tsx     |  87 ++++++++++++++
 src/i18n/strings/en_EN.json                   |   2 +-
 .../settings/devices/DeviceTile-test.tsx      | 107 ++++++++++++++++++
 .../__snapshots__/DeviceTile-test.tsx.snap    |  93 +++++++++++++++
 8 files changed, 334 insertions(+), 35 deletions(-)
 create mode 100644 res/css/components/views/settings/devices/_DeviceTile.pcss
 create mode 100644 src/components/views/settings/devices/DeviceTile.tsx
 create mode 100644 test/components/views/settings/devices/DeviceTile-test.tsx
 create mode 100644 test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap

diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index e1303dcd60..fe23a1c388 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -27,6 +27,7 @@
 @import "./components/views/location/_ZoomButtons.pcss";
 @import "./components/views/messages/_MBeaconBody.pcss";
 @import "./components/views/messages/shared/_MediaProcessingError.pcss";
+@import "./components/views/settings/devices/_DeviceTile.pcss";
 @import "./components/views/spaces/_QuickThemeSwitcher.pcss";
 @import "./structures/_AutoHideScrollbar.pcss";
 @import "./structures/_BackdropPanel.pcss";
diff --git a/res/css/components/views/settings/devices/_DeviceTile.pcss b/res/css/components/views/settings/devices/_DeviceTile.pcss
new file mode 100644
index 0000000000..159cace6ac
--- /dev/null
+++ b/res/css/components/views/settings/devices/_DeviceTile.pcss
@@ -0,0 +1,41 @@
+/*
+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_DeviceTile {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+
+    width: 100%;
+}
+
+.mx_DeviceTile_info {
+    flex: 1 1 0;
+}
+
+.mx_DeviceTile_metadata {
+    margin-top: 2px;
+    font-size: $font-12px;
+    color: $secondary-content;
+}
+
+.mx_DeviceTile_actions {
+    display: grid;
+    grid-gap: $spacing-8;
+    grid-auto-flow: column;
+
+    margin-left: $spacing-8;
+}
diff --git a/res/css/rethemendex.sh b/res/css/rethemendex.sh
index 1fc1bb84cc..37090b96d8 100755
--- a/res/css/rethemendex.sh
+++ b/res/css/rethemendex.sh
@@ -3,7 +3,7 @@
 cd `dirname $0`
 
 {
-    echo "// autogenerated by rethemendex.sh"
+    echo "/* autogenerated by rethemendex.sh */"
 
     # we used to have exclude /themes from the find at this point.
     # as themes are no longer a spurious subdirectory of css/, we don't
diff --git a/src/components/views/settings/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx
index 2e7094c7b3..5a5330fd3e 100644
--- a/src/components/views/settings/DevicesPanelEntry.tsx
+++ b/src/components/views/settings/DevicesPanelEntry.tsx
@@ -20,15 +20,14 @@ import { logger } from "matrix-js-sdk/src/logger";
 
 import { _t } from '../../../languageHandler';
 import { MatrixClientPeg } from '../../../MatrixClientPeg';
-import { formatDate } from '../../../DateUtils';
 import StyledCheckbox, { CheckboxStyle } from '../elements/StyledCheckbox';
 import AccessibleButton from "../elements/AccessibleButton";
 import Field from "../elements/Field";
-import TextWithTooltip from "../elements/TextWithTooltip";
 import Modal from "../../../Modal";
 import SetupEncryptionDialog from '../dialogs/security/SetupEncryptionDialog';
 import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog';
 import LogoutDialog from '../dialogs/LogoutDialog';
+import DeviceTile from './devices/DeviceTile';
 
 interface IProps {
     device: IMyDevice;
@@ -114,17 +113,6 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
     };
 
     public render(): JSX.Element {
-        const device = this.props.device;
-
-        let lastSeen = "";
-        if (device.last_seen_ts) {
-            const lastSeenDate = new Date(device.last_seen_ts);
-            lastSeen = _t("Last seen %(date)s at %(ip)s", {
-                date: formatDate(lastSeenDate),
-                ip: device.last_seen_ip,
-            });
-        }
-
         const myDeviceClass = this.props.isOwnDevice ? " mx_DevicesPanel_myDevice" : '';
 
         let iconClass = '';
@@ -153,16 +141,6 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
                 <StyledCheckbox kind={CheckboxStyle.Outline} onChange={this.onDeviceToggled} checked={this.props.selected} />
             </div>;
 
-        const deviceName = device.display_name ?
-            <React.Fragment>
-                <TextWithTooltip tooltip={device.display_name + " (" + device.device_id + ")"}>
-                    { device.display_name }
-                </TextWithTooltip>
-            </React.Fragment> :
-            <React.Fragment>
-                { device.device_id }
-            </React.Fragment>;
-
         const buttons = this.state.renaming ?
             <form className="mx_DevicesPanel_renameForm" onSubmit={this.onRenameSubmit}>
                 <Field
@@ -187,17 +165,9 @@ export default class DevicesPanelEntry extends React.Component<IProps, IState> {
         return (
             <div className={"mx_DevicesPanel_device" + myDeviceClass}>
                 { left }
-                <div className="mx_DevicesPanel_deviceInfo">
-                    <div className="mx_DevicesPanel_deviceName">
-                        { deviceName }
-                    </div>
-                    <div className="mx_DevicesPanel_lastSeen">
-                        { lastSeen }
-                    </div>
-                </div>
-                <div className="mx_DevicesPanel_deviceButtons">
+                <DeviceTile device={this.props.device}>
                     { buttons }
-                </div>
+                </DeviceTile>
             </div>
         );
     }
diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx
new file mode 100644
index 0000000000..03d952fbb1
--- /dev/null
+++ b/src/components/views/settings/devices/DeviceTile.tsx
@@ -0,0 +1,87 @@
+/*
+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, { Fragment } from "react";
+import { IMyDevice } from "matrix-js-sdk/src/matrix";
+
+import { _t } from "../../../../languageHandler";
+import { formatDate, formatRelativeTime } from "../../../../DateUtils";
+import TooltipTarget from "../../elements/TooltipTarget";
+import { Alignment } from "../../elements/Tooltip";
+import Heading from "../../typography/Heading";
+
+interface Props {
+    device: IMyDevice;
+    children?: React.ReactNode;
+}
+
+const DeviceTileName: React.FC<{ device: IMyDevice }> = ({ device }) => {
+    if (device.display_name) {
+        return <TooltipTarget
+            alignment={Alignment.Left}
+            label={`${device.display_name} (${device.device_id})`}
+        >
+            <Heading size='h4'>
+                { device.display_name }
+            </Heading>
+        </TooltipTarget>;
+    }
+    return <Heading size='h4'>
+        { device.device_id }
+    </Heading>;
+};
+
+const MS_6_DAYS = 6 * 24 * 60 * 60 * 1000;
+const formatLastActivity = (timestamp: number, now = new Date().getTime()): string => {
+    // less than a week ago
+    if (timestamp + MS_6_DAYS >= now) {
+        const date = new Date(timestamp);
+        // Tue 20:15
+        return formatDate(date);
+    }
+    return formatRelativeTime(new Date(timestamp));
+};
+
+const DeviceMetadata: React.FC<{ value: string, id: string }> = ({ value, id }) => (
+    value ? <span data-testid={`device-metadata-${id}`}>{ value }</span> : null
+);
+
+const DeviceTile: React.FC<Props> = ({ device, children }) => {
+    const lastActivity = device.last_seen_ts && `${_t('Last activity')} ${formatLastActivity(device.last_seen_ts)}`;
+    const metadata = [
+        { id: 'lastActivity', value: lastActivity },
+        { id: 'lastSeenIp', value: device.last_seen_ip },
+    ];
+
+    return <div className="mx_DeviceTile">
+        <div className="mx_DeviceTile_info">
+            <DeviceTileName device={device} />
+            <div className="mx_DeviceTile_metadata">
+                { metadata.map(({ id, value }, index) =>
+                    <Fragment key={id}>
+                        { !!index && ' · ' }
+                        <DeviceMetadata id={id} value={value} />
+                    </Fragment>,
+                ) }
+            </div>
+        </div>
+        <div className="mx_DeviceTile_actions">
+            { children }
+        </div>
+    </div>;
+};
+
+export default DeviceTile;
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 07b563820d..7ce4908afe 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1303,7 +1303,6 @@
     "You aren't signed into any other devices.": "You aren't signed into any other devices.",
     "This device": "This device",
     "Failed to set display name": "Failed to set display name",
-    "Last seen %(date)s at %(ip)s": "Last seen %(date)s at %(ip)s",
     "Sign Out": "Sign Out",
     "Display Name": "Display Name",
     "Rename": "Rename",
@@ -1691,6 +1690,7 @@
     "Please enter verification code sent via text.": "Please enter verification code sent via text.",
     "Verification code": "Verification code",
     "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.",
+    "Last activity": "Last activity",
     "Unable to remove contact information": "Unable to remove contact information",
     "Remove %(email)s?": "Remove %(email)s?",
     "Invalid Email Address": "Invalid Email Address",
diff --git a/test/components/views/settings/devices/DeviceTile-test.tsx b/test/components/views/settings/devices/DeviceTile-test.tsx
new file mode 100644
index 0000000000..d688eca913
--- /dev/null
+++ b/test/components/views/settings/devices/DeviceTile-test.tsx
@@ -0,0 +1,107 @@
+/*
+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 { render } from '@testing-library/react';
+import { IMyDevice } from 'matrix-js-sdk/src/matrix';
+
+import DeviceTile from '../../../../../src/components/views/settings/devices/DeviceTile';
+
+describe('<DeviceTile />', () => {
+    const defaultProps = {
+        device: {
+            device_id: '123',
+        },
+    };
+    const getComponent = (props = {}) => (
+        <DeviceTile {...defaultProps} {...props} />
+    );
+    // 14.03.2022 16:15
+    const now = 1647270879403;
+
+    jest.useFakeTimers();
+
+    beforeEach(() => {
+        jest.setSystemTime(now);
+    });
+
+    it('renders a device with no metadata', () => {
+        const { container } = render(getComponent());
+        expect(container).toMatchSnapshot();
+    });
+
+    it('renders display name with a tooltip', () => {
+        const device: IMyDevice = {
+            device_id: '123',
+            display_name: 'My device',
+        };
+        const { container } = render(getComponent({ device }));
+        expect(container).toMatchSnapshot();
+    });
+
+    it('renders last seen ip metadata', () => {
+        const device: IMyDevice = {
+            device_id: '123',
+            display_name: 'My device',
+            last_seen_ip: '1.2.3.4',
+        };
+        const { getByTestId } = render(getComponent({ device }));
+        expect(getByTestId('device-metadata-lastSeenIp').textContent).toEqual(device.last_seen_ip);
+    });
+
+    it('separates metadata with a dot', () => {
+        const device: IMyDevice = {
+            device_id: '123',
+            last_seen_ip: '1.2.3.4',
+            last_seen_ts: now - 60000,
+        };
+        const { container } = render(getComponent({ device }));
+        expect(container).toMatchSnapshot();
+    });
+
+    describe('Last activity', () => {
+        const MS_DAY = 24 * 60 * 60 * 1000;
+        it('renders with day of week and time when last activity is less than 6 days ago', () => {
+            const device: IMyDevice = {
+                device_id: '123',
+                last_seen_ip: '1.2.3.4',
+                last_seen_ts: now - (MS_DAY * 3),
+            };
+            const { getByTestId } = render(getComponent({ device }));
+            expect(getByTestId('device-metadata-lastActivity').textContent).toEqual('Last activity Fri 15:14');
+        });
+
+        it('renders with month and date when last activity is more than 6 days ago', () => {
+            const device: IMyDevice = {
+                device_id: '123',
+                last_seen_ip: '1.2.3.4',
+                last_seen_ts: now - (MS_DAY * 8),
+            };
+            const { getByTestId } = render(getComponent({ device }));
+            expect(getByTestId('device-metadata-lastActivity').textContent).toEqual('Last activity Mar 6');
+        });
+
+        it('renders with month, date, year when activity is in a different calendar year', () => {
+            const device: IMyDevice = {
+                device_id: '123',
+                last_seen_ip: '1.2.3.4',
+                last_seen_ts: new Date('2021-12-29').getTime(),
+            };
+            const { getByTestId } = render(getComponent({ device }));
+            expect(getByTestId('device-metadata-lastActivity').textContent).toEqual('Last activity Dec 29, 2021');
+        });
+    });
+});
diff --git a/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap
new file mode 100644
index 0000000000..299d72348c
--- /dev/null
+++ b/test/components/views/settings/devices/__snapshots__/DeviceTile-test.tsx.snap
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<DeviceTile /> renders a device with no metadata 1`] = `
+<div>
+  <div
+    class="mx_DeviceTile"
+  >
+    <div
+      class="mx_DeviceTile_info"
+    >
+      <h4
+        class="mx_Heading_h4"
+      >
+        123
+      </h4>
+      <div
+        class="mx_DeviceTile_metadata"
+      >
+         · 
+      </div>
+    </div>
+    <div
+      class="mx_DeviceTile_actions"
+    />
+  </div>
+</div>
+`;
+
+exports[`<DeviceTile /> renders display name with a tooltip 1`] = `
+<div>
+  <div
+    class="mx_DeviceTile"
+  >
+    <div
+      class="mx_DeviceTile_info"
+    >
+      <div
+        tabindex="0"
+      >
+        <h4
+          class="mx_Heading_h4"
+        >
+          My device
+        </h4>
+      </div>
+      <div
+        class="mx_DeviceTile_metadata"
+      >
+         · 
+      </div>
+    </div>
+    <div
+      class="mx_DeviceTile_actions"
+    />
+  </div>
+</div>
+`;
+
+exports[`<DeviceTile /> separates metadata with a dot 1`] = `
+<div>
+  <div
+    class="mx_DeviceTile"
+  >
+    <div
+      class="mx_DeviceTile_info"
+    >
+      <h4
+        class="mx_Heading_h4"
+      >
+        123
+      </h4>
+      <div
+        class="mx_DeviceTile_metadata"
+      >
+        <span
+          data-testid="device-metadata-lastActivity"
+        >
+          Last activity 15:13
+        </span>
+         · 
+        <span
+          data-testid="device-metadata-lastSeenIp"
+        >
+          1.2.3.4
+        </span>
+      </div>
+    </div>
+    <div
+      class="mx_DeviceTile_actions"
+    />
+  </div>
+</div>
+`;