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 { }; 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 { ; - const deviceName = device.display_name ? - - - { device.display_name } - - : - - { device.device_id } - ; - const buttons = this.state.renaming ?
{ return (
{ left } -
-
- { deviceName } -
-
- { lastSeen } -
-
-
+ { buttons } -
+
); } 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 + + { device.display_name } + + ; + } + return + { device.device_id } + ; +}; + +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 ? { value } : null +); + +const DeviceTile: React.FC = ({ 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
+
+ +
+ { metadata.map(({ id, value }, index) => + + { !!index && ' · ' } + + , + ) } +
+
+
+ { children } +
+
; +}; + +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('', () => { + const defaultProps = { + device: { + device_id: '123', + }, + }; + const getComponent = (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[` renders a device with no metadata 1`] = ` +
+
+
+

+ 123 +

+ +
+
+
+
+`; + +exports[` renders display name with a tooltip 1`] = ` +
+
+
+
+

+ My device +

+
+ +
+
+
+
+`; + +exports[` separates metadata with a dot 1`] = ` +
+
+
+

+ 123 +

+ +
+
+
+
+`;