From 859fdf7d51cb04c7926c370aee90b007bdf86d67 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 20 Apr 2022 09:44:06 +0100 Subject: [PATCH 1/4] Cache localStorage objects for SettingsStore (#8366) --- .../AbstractLocalStorageSettingsHandler.ts | 87 +++++++++++++++++++ .../handlers/DeviceSettingsHandler.ts | 34 +++----- .../handlers/RoomDeviceSettingsHandler.ts | 23 ++--- 3 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 src/settings/handlers/AbstractLocalStorageSettingsHandler.ts diff --git a/src/settings/handlers/AbstractLocalStorageSettingsHandler.ts b/src/settings/handlers/AbstractLocalStorageSettingsHandler.ts new file mode 100644 index 00000000000..5d64009b6fe --- /dev/null +++ b/src/settings/handlers/AbstractLocalStorageSettingsHandler.ts @@ -0,0 +1,87 @@ +/* +Copyright 2019 - 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 SettingsHandler from "./SettingsHandler"; + +/** + * Abstract settings handler wrapping around localStorage making getValue calls cheaper + * by caching the values and listening for localStorage updates from other tabs. + */ +export default abstract class AbstractLocalStorageSettingsHandler extends SettingsHandler { + private itemCache = new Map(); + private objectCache = new Map(); + + protected constructor() { + super(); + + // Listen for storage changes from other tabs to bust the cache + window.addEventListener("storage", (e: StorageEvent) => { + if (e.key === null) { + this.itemCache.clear(); + this.objectCache.clear(); + } else { + this.itemCache.delete(e.key); + this.objectCache.delete(e.key); + } + }); + } + + protected getItem(key: string): any { + if (!this.itemCache.has(key)) { + const value = localStorage.getItem(key); + this.itemCache.set(key, value); + return value; + } + + return this.itemCache.get(key); + } + + protected getObject(key: string): T | null { + if (!this.objectCache.has(key)) { + try { + const value = JSON.parse(localStorage.getItem(key)); + this.objectCache.set(key, value); + return value; + } catch (err) { + console.error("Failed to parse localStorage object", err); + return null; + } + } + + return this.objectCache.get(key) as T; + } + + protected setItem(key: string, value: any): void { + this.itemCache.set(key, value); + localStorage.setItem(key, value); + } + + protected setObject(key: string, value: object): void { + this.objectCache.set(key, value); + localStorage.setItem(key, JSON.stringify(value)); + } + + // handles both items and objects + protected removeItem(key: string): void { + localStorage.removeItem(key); + this.itemCache.delete(key); + this.objectCache.delete(key); + } + + public isSupported(): boolean { + return localStorage !== undefined && localStorage !== null; + } +} diff --git a/src/settings/handlers/DeviceSettingsHandler.ts b/src/settings/handlers/DeviceSettingsHandler.ts index 7d2fbaf236a..25c75c67a19 100644 --- a/src/settings/handlers/DeviceSettingsHandler.ts +++ b/src/settings/handlers/DeviceSettingsHandler.ts @@ -1,7 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd. -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 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. @@ -16,17 +16,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsHandler from "./SettingsHandler"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SettingLevel } from "../SettingLevel"; import { CallbackFn, WatchManager } from "../WatchManager"; +import AbstractLocalStorageSettingsHandler from "./AbstractLocalStorageSettingsHandler"; /** * Gets and sets settings at the "device" level for the current device. * This handler does not make use of the roomId parameter. This handler * will special-case features to support legacy settings. */ -export default class DeviceSettingsHandler extends SettingsHandler { +export default class DeviceSettingsHandler extends AbstractLocalStorageSettingsHandler { /** * Creates a new device settings handler * @param {string[]} featureNames The names of known features. @@ -43,15 +43,15 @@ export default class DeviceSettingsHandler extends SettingsHandler { // Special case notifications if (settingName === "notificationsEnabled") { - const value = localStorage.getItem("notifications_enabled"); + const value = this.getItem("notifications_enabled"); if (typeof(value) === "string") return value === "true"; return null; // wrong type or otherwise not set } else if (settingName === "notificationBodyEnabled") { - const value = localStorage.getItem("notifications_body_enabled"); + const value = this.getItem("notifications_body_enabled"); if (typeof(value) === "string") return value === "true"; return null; // wrong type or otherwise not set } else if (settingName === "audioNotificationsEnabled") { - const value = localStorage.getItem("audio_notifications_enabled"); + const value = this.getItem("audio_notifications_enabled"); if (typeof(value) === "string") return value === "true"; return null; // wrong type or otherwise not set } @@ -68,15 +68,15 @@ export default class DeviceSettingsHandler extends SettingsHandler { // Special case notifications if (settingName === "notificationsEnabled") { - localStorage.setItem("notifications_enabled", newValue); + this.setItem("notifications_enabled", newValue); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } else if (settingName === "notificationBodyEnabled") { - localStorage.setItem("notifications_body_enabled", newValue); + this.setItem("notifications_body_enabled", newValue); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } else if (settingName === "audioNotificationsEnabled") { - localStorage.setItem("audio_notifications_enabled", newValue); + this.setItem("audio_notifications_enabled", newValue); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); } @@ -87,7 +87,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { delete settings["useIRCLayout"]; settings["layout"] = newValue; - localStorage.setItem("mx_local_settings", JSON.stringify(settings)); + this.setObject("mx_local_settings", settings); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); @@ -95,7 +95,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { const settings = this.getSettings() || {}; settings[settingName] = newValue; - localStorage.setItem("mx_local_settings", JSON.stringify(settings)); + this.setObject("mx_local_settings", settings); this.watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); return Promise.resolve(); @@ -105,10 +105,6 @@ export default class DeviceSettingsHandler extends SettingsHandler { return true; // It's their device, so they should be able to } - public isSupported(): boolean { - return localStorage !== undefined && localStorage !== null; - } - public watchSetting(settingName: string, roomId: string, cb: CallbackFn) { this.watchers.watchSetting(settingName, roomId, cb); } @@ -118,9 +114,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { } private getSettings(): any { // TODO: [TS] Type return - const value = localStorage.getItem("mx_local_settings"); - if (!value) return null; - return JSON.parse(value); + return this.getObject("mx_local_settings"); } // Note: features intentionally don't use the same key as settings to avoid conflicts @@ -132,7 +126,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { return false; } - const value = localStorage.getItem("mx_labs_feature_" + featureName); + const value = this.getItem("mx_labs_feature_" + featureName); if (value === "true") return true; if (value === "false") return false; // Try to read the next config level for the feature. @@ -140,7 +134,7 @@ export default class DeviceSettingsHandler extends SettingsHandler { } private writeFeature(featureName: string, enabled: boolean | null) { - localStorage.setItem("mx_labs_feature_" + featureName, `${enabled}`); + this.setItem("mx_labs_feature_" + featureName, `${enabled}`); this.watchers.notifyUpdate(featureName, null, SettingLevel.DEVICE, enabled); } } diff --git a/src/settings/handlers/RoomDeviceSettingsHandler.ts b/src/settings/handlers/RoomDeviceSettingsHandler.ts index 47fcecdfacd..c1d1b57e9b6 100644 --- a/src/settings/handlers/RoomDeviceSettingsHandler.ts +++ b/src/settings/handlers/RoomDeviceSettingsHandler.ts @@ -1,6 +1,6 @@ /* Copyright 2017 Travis Ralston -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 - 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. @@ -15,15 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import SettingsHandler from "./SettingsHandler"; import { SettingLevel } from "../SettingLevel"; import { WatchManager } from "../WatchManager"; +import AbstractLocalStorageSettingsHandler from "./AbstractLocalStorageSettingsHandler"; /** * Gets and sets settings at the "room-device" level for the current device in a particular * room. */ -export default class RoomDeviceSettingsHandler extends SettingsHandler { +export default class RoomDeviceSettingsHandler extends AbstractLocalStorageSettingsHandler { constructor(public readonly watchers: WatchManager) { super(); } @@ -32,7 +32,7 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { // Special case blacklist setting to use legacy values if (settingName === "blacklistUnverifiedDevices") { const value = this.read("mx_local_settings"); - if (value && value['blacklistUnverifiedDevicesPerRoom']) { + if (value?.['blacklistUnverifiedDevicesPerRoom']) { return value['blacklistUnverifiedDevicesPerRoom'][roomId]; } } @@ -49,16 +49,15 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { if (!value) value = {}; if (!value["blacklistUnverifiedDevicesPerRoom"]) value["blacklistUnverifiedDevicesPerRoom"] = {}; value["blacklistUnverifiedDevicesPerRoom"][roomId] = newValue; - localStorage.setItem("mx_local_settings", JSON.stringify(value)); + this.setObject("mx_local_settings", value); this.watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_DEVICE, newValue); return Promise.resolve(); } if (newValue === null) { - localStorage.removeItem(this.getKey(settingName, roomId)); + this.removeItem(this.getKey(settingName, roomId)); } else { - newValue = JSON.stringify({ value: newValue }); - localStorage.setItem(this.getKey(settingName, roomId), newValue); + this.setObject(this.getKey(settingName, roomId), { value: newValue }); } this.watchers.notifyUpdate(settingName, roomId, SettingLevel.ROOM_DEVICE, newValue); @@ -69,14 +68,8 @@ export default class RoomDeviceSettingsHandler extends SettingsHandler { return true; // It's their device, so they should be able to } - public isSupported(): boolean { - return localStorage !== undefined && localStorage !== null; - } - private read(key: string): any { - const rawValue = localStorage.getItem(key); - if (!rawValue) return null; - return JSON.parse(rawValue); + return this.getItem(key); } private getKey(settingName: string, roomId: string): string { From 2f6b76755c658e722ba55ae850b3c26ab384ee4c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 20 Apr 2022 11:05:55 +0100 Subject: [PATCH 2/4] Fix RightPanelStore handling first room on app launch wrong (#8370) --- src/stores/right-panel/RightPanelStore.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 1d505b9b22b..bb4ddc4fcbf 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -34,6 +34,7 @@ import { import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { ActiveRoomChangedPayload } from "../../dispatcher/payloads/ActiveRoomChangedPayload"; +import { RoomViewStore } from "../RoomViewStore"; /** * A class for tracking the state of the right panel between layouts and @@ -55,6 +56,7 @@ export default class RightPanelStore extends ReadyWatchingStore { } protected async onReady(): Promise { + this.viewedRoomId = RoomViewStore.instance.getRoomId(); this.matrixClient.on(CryptoEvent.VerificationRequest, this.onVerificationRequestUpdate); this.loadCacheFromSettings(); this.emitAndUpdateSettings(); @@ -348,6 +350,7 @@ export default class RightPanelStore extends ReadyWatchingStore { }; private handleViewedRoomChange(oldRoomId: Optional, newRoomId: Optional) { + if (!this.mxClient) return; // not ready, onReady will handle the first room this.viewedRoomId = newRoomId; // load values from byRoomCache with the viewedRoomId. this.loadCacheFromSettings(); From 4a38cbd550edf42477c901e5147527fd8326562c Mon Sep 17 00:00:00 2001 From: Kerry Date: Wed, 20 Apr 2022 13:57:50 +0200 Subject: [PATCH 3/4] Live location sharing: beacon list view tiles (#8363) * add basic sidebar container Signed-off-by: Kerry Archibald * optionally show icon in beaconstatus Signed-off-by: Kerry Archibald * add avatar and style list item Signed-off-by: Kerry Archibald * formatted last update time Signed-off-by: Kerry Archibald * test beacon list item Signed-off-by: Kerry Archibald * move makeRoomWithState events to test utils Signed-off-by: Kerry Archibald * move beacon test helpers into utils Signed-off-by: Kerry Archibald * newline Signed-off-by: Kerry Archibald * add copyable text to beacon list item Signed-off-by: Kerry Archibald * add copyable geo uri to list item Signed-off-by: Kerry Archibald * improve spacing Signed-off-by: Kerry Archibald * overflow scroll on list Signed-off-by: Kerry Archibald --- res/css/_components.scss | 1 + .../views/beacon/_BeaconListItem.scss | 61 ++++++ .../views/beacon/_BeaconStatus.scss | 4 + .../views/beacon/_DialogSidebar.scss | 5 +- .../views/beacon/BeaconListItem.tsx | 82 +++++++++ src/components/views/beacon/BeaconStatus.tsx | 8 +- src/components/views/beacon/DialogSidebar.tsx | 4 +- .../views/beacon/OwnBeaconStatus.tsx | 1 + .../views/elements/CopyableText.tsx | 2 +- src/components/views/messages/MBeaconBody.tsx | 1 + src/i18n/strings/en_EN.json | 1 + src/utils/humanize.ts | 2 +- .../views/beacon/BeaconListItem-test.tsx | 173 ++++++++++++++++++ .../views/beacon/BeaconStatus-test.tsx | 6 + .../views/beacon/DialogSidebar-test.tsx | 1 - .../BeaconListItem-test.tsx.snap | 3 + .../__snapshots__/BeaconStatus-test.tsx.snap | 9 +- .../OwnBeaconStatus-test.tsx.snap | 1 + 18 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 res/css/components/views/beacon/_BeaconListItem.scss create mode 100644 src/components/views/beacon/BeaconListItem.tsx create mode 100644 test/components/views/beacon/BeaconListItem-test.tsx create mode 100644 test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap diff --git a/res/css/_components.scss b/res/css/_components.scss index aa92057e633..7032c35f39c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -4,6 +4,7 @@ @import "./_font-sizes.scss"; @import "./_font-weights.scss"; @import "./_spacing.scss"; +@import "./components/views/beacon/_BeaconListItem.scss"; @import "./components/views/beacon/_BeaconStatus.scss"; @import "./components/views/beacon/_BeaconViewDialog.scss"; @import "./components/views/beacon/_DialogSidebar.scss"; diff --git a/res/css/components/views/beacon/_BeaconListItem.scss b/res/css/components/views/beacon/_BeaconListItem.scss new file mode 100644 index 00000000000..60311a4466f --- /dev/null +++ b/res/css/components/views/beacon/_BeaconListItem.scss @@ -0,0 +1,61 @@ +/* +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_BeaconListItem { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: flex-start; + padding: $spacing-12 0; + + border-bottom: 1px solid $system; +} + +.mx_BeaconListItem_avatarIcon { + flex: 0 0; + height: 32px; + width: 32px; +} + +.mx_BeaconListItem_avatar { + flex: 0 0; + box-sizing: border-box; + + margin-right: $spacing-8; + border: 2px solid $location-live-color; +} + +.mx_BeaconListItem_info { + flex: 1 1 0; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.mx_BeaconListItem_status { + // override beacon status padding + padding: 0 !important; + margin-bottom: $spacing-8; + + .mx_BeaconStatus_label { + font-weight: $font-semi-bold; + } +} + +.mx_BeaconListItem_lastUpdated { + color: $tertiary-content; + font-size: $font-10px; +} diff --git a/res/css/components/views/beacon/_BeaconStatus.scss b/res/css/components/views/beacon/_BeaconStatus.scss index 8ac873604d2..4dd3d325475 100644 --- a/res/css/components/views/beacon/_BeaconStatus.scss +++ b/res/css/components/views/beacon/_BeaconStatus.scss @@ -59,3 +59,7 @@ limitations under the License. .mx_BeaconStatus_expiryTime { color: $secondary-content; } + +.mx_BeaconStatus_label { + margin-bottom: 2px; +} diff --git a/res/css/components/views/beacon/_DialogSidebar.scss b/res/css/components/views/beacon/_DialogSidebar.scss index 02d0e82cc33..1989b57c301 100644 --- a/res/css/components/views/beacon/_DialogSidebar.scss +++ b/res/css/components/views/beacon/_DialogSidebar.scss @@ -21,6 +21,9 @@ limitations under the License. height: 100%; width: 265px; + display: flex; + flex-direction: column; + box-sizing: border-box; padding: $spacing-16; @@ -34,7 +37,7 @@ limitations under the License. align-items: center; justify-content: space-between; - flex: 0; + flex: 0 0; margin-bottom: $spacing-16; color: $primary-content; diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx new file mode 100644 index 00000000000..eda1580700e --- /dev/null +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -0,0 +1,82 @@ +/* +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, { useContext } from 'react'; +import { Beacon, BeaconEvent } from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; + +import MatrixClientContext from '../../../contexts/MatrixClientContext'; +import { useEventEmitterState } from '../../../hooks/useEventEmitter'; +import { humanizeTime } from '../../../utils/humanize'; +import { _t } from '../../../languageHandler'; +import MemberAvatar from '../avatars/MemberAvatar'; +import CopyableText from '../elements/CopyableText'; +import BeaconStatus from './BeaconStatus'; +import { BeaconDisplayStatus } from './displayStatus'; +import StyledLiveBeaconIcon from './StyledLiveBeaconIcon'; + +interface Props { + beacon: Beacon; +} + +const BeaconListItem: React.FC = ({ beacon }) => { + const latestLocationState = useEventEmitterState( + beacon, + BeaconEvent.LocationUpdate, + () => beacon.latestLocationState, + ); + const matrixClient = useContext(MatrixClientContext); + const room = matrixClient.getRoom(beacon.roomId); + + if (!latestLocationState || !beacon.isLive) { + return null; + } + + const isSelfLocation = beacon.beaconInfo.assetType === LocationAssetType.Self; + const beaconMember = isSelfLocation ? + room.getMember(beacon.beaconInfoOwner) : + undefined; + + const humanizedUpdateTime = humanizeTime(latestLocationState.timestamp); + + return
  • + { isSelfLocation ? + : + + } +
    + + latestLocationState?.uri} + /> + + { _t("Updated %(humanizedUpdateTime)s", { humanizedUpdateTime }) } +
    +
  • ; +}; + +export default BeaconListItem; diff --git a/src/components/views/beacon/BeaconStatus.tsx b/src/components/views/beacon/BeaconStatus.tsx index c9d7bd3762d..935e22f4f0b 100644 --- a/src/components/views/beacon/BeaconStatus.tsx +++ b/src/components/views/beacon/BeaconStatus.tsx @@ -28,6 +28,7 @@ import { formatTime } from '../../../DateUtils'; interface Props { displayStatus: BeaconDisplayStatus; displayLiveTimeRemaining?: boolean; + withIcon?: boolean; beacon?: Beacon; label?: string; } @@ -45,6 +46,7 @@ const BeaconStatus: React.FC> = label, className, children, + withIcon, ...rest }) => { const isIdle = displayStatus === BeaconDisplayStatus.Loading || @@ -54,11 +56,11 @@ const BeaconStatus: React.FC> = {...rest} className={classNames('mx_BeaconStatus', `mx_BeaconStatus_${displayStatus}`, className)} > - + /> }
    { displayStatus === BeaconDisplayStatus.Loading && { _t('Loading live location...') } } @@ -68,7 +70,7 @@ const BeaconStatus: React.FC> = { displayStatus === BeaconDisplayStatus.Active && beacon && <> <> - { label } + { label } { displayLiveTimeRemaining ? : diff --git a/src/components/views/beacon/DialogSidebar.tsx b/src/components/views/beacon/DialogSidebar.tsx index fac91c77cbc..4365b5fa8b6 100644 --- a/src/components/views/beacon/DialogSidebar.tsx +++ b/src/components/views/beacon/DialogSidebar.tsx @@ -21,6 +21,7 @@ import { Icon as CloseIcon } from '../../../../res/img/image-view/close.svg'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; import Heading from '../typography/Heading'; +import BeaconListItem from './BeaconListItem'; interface Props { beacons: Beacon[]; @@ -41,8 +42,7 @@ const DialogSidebar: React.FC = ({ beacons, requestClose }) => {
      - { /* TODO nice elements */ } - { beacons.map((beacon, index) =>
    1. { index }
    2. ) } + { beacons.map((beacon) => ) }
    ; }; diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx index 204e2968293..0a682b11641 100644 --- a/src/components/views/beacon/OwnBeaconStatus.tsx +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -54,6 +54,7 @@ const OwnBeaconStatus: React.FC> = ({ displayStatus={ownDisplayStatus} label={_t('Live location enabled')} displayLiveTimeRemaining + withIcon {...rest} > { ownDisplayStatus === BeaconDisplayStatus.Active && string; border?: boolean; } diff --git a/src/components/views/messages/MBeaconBody.tsx b/src/components/views/messages/MBeaconBody.tsx index 4beac791019..bd7e10f044d 100644 --- a/src/components/views/messages/MBeaconBody.tsx +++ b/src/components/views/messages/MBeaconBody.tsx @@ -152,6 +152,7 @@ const MBeaconBody: React.FC = React.forwardRef(({ mxEvent }, ref) => beacon={beacon} displayStatus={displayStatus} label={_t('View live location')} + withIcon /> } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 24674408792..c6a66c79678 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2913,6 +2913,7 @@ "Click for more info": "Click for more info", "Beta": "Beta", "Join the beta": "Join the beta", + "Updated %(humanizedUpdateTime)s": "Updated %(humanizedUpdateTime)s", "Live until %(expiryTime)s": "Live until %(expiryTime)s", "Loading live location...": "Loading live location...", "Live location ended": "Live location ended", diff --git a/src/utils/humanize.ts b/src/utils/humanize.ts index 978d17424b3..47e2d83e8a0 100644 --- a/src/utils/humanize.ts +++ b/src/utils/humanize.ts @@ -30,7 +30,7 @@ const HOURS_1_DAY = 26; * @returns {string} The humanized time. */ export function humanizeTime(timeMillis: number): string { - const now = (new Date()).getTime(); + const now = Date.now(); let msAgo = now - timeMillis; const minutes = Math.abs(Math.ceil(msAgo / 60000)); const hours = Math.ceil(minutes / 60); diff --git a/test/components/views/beacon/BeaconListItem-test.tsx b/test/components/views/beacon/BeaconListItem-test.tsx new file mode 100644 index 00000000000..e7e9fbb7265 --- /dev/null +++ b/test/components/views/beacon/BeaconListItem-test.tsx @@ -0,0 +1,173 @@ +/* +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 { mount } from 'enzyme'; +import { + Beacon, + RoomMember, + MatrixEvent, +} from 'matrix-js-sdk/src/matrix'; +import { LocationAssetType } from 'matrix-js-sdk/src/@types/location'; +import { act } from 'react-dom/test-utils'; + +import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem'; +import MatrixClientContext from '../../../../src/contexts/MatrixClientContext'; +import { + getMockClientWithEventEmitter, + makeBeaconEvent, + makeBeaconInfoEvent, + makeRoomWithBeacons, +} from '../../../test-utils'; + +describe('', () => { + // 14.03.2022 16:15 + const now = 1647270879403; + // go back in time to create beacons and locations in the past + jest.spyOn(global.Date, 'now').mockReturnValue(now - 600000); + const roomId = '!room:server'; + const aliceId = '@alice:server'; + + const mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue(aliceId), + getRoom: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + }); + + const aliceBeaconEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true }, + '$alice-room1-1', + ); + const alicePinBeaconEvent = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true, assetType: LocationAssetType.Pin, description: "Alice's car" }, + '$alice-room1-1', + ); + const pinBeaconWithoutDescription = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: true, assetType: LocationAssetType.Pin }, + '$alice-room1-1', + ); + + const aliceLocation1 = makeBeaconEvent( + aliceId, { beaconInfoId: aliceBeaconEvent.getId(), geoUri: 'geo:51,41', timestamp: now - 1 }, + ); + const aliceLocation2 = makeBeaconEvent( + aliceId, { beaconInfoId: aliceBeaconEvent.getId(), geoUri: 'geo:52,42', timestamp: now - 500000 }, + ); + + const defaultProps = { + beacon: new Beacon(aliceBeaconEvent), + }; + + const getComponent = (props = {}) => + mount(, { + wrappingComponent: MatrixClientContext.Provider, + wrappingComponentProps: { value: mockClient }, + }); + + const setupRoomWithBeacons = (beaconInfoEvents: MatrixEvent[], locationEvents?: MatrixEvent[]): Beacon[] => { + const beacons = makeRoomWithBeacons(roomId, mockClient, beaconInfoEvents, locationEvents); + + const member = new RoomMember(roomId, aliceId); + member.name = `Alice`; + const room = mockClient.getRoom(roomId); + jest.spyOn(room, 'getMember').mockReturnValue(member); + + return beacons; + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Date, 'now').mockReturnValue(now); + }); + + it('renders null when beacon is not live', () => { + const notLiveBeacon = makeBeaconInfoEvent(aliceId, + roomId, + { isLive: false }, + ); + const [beacon] = setupRoomWithBeacons([notLiveBeacon]); + const component = getComponent({ beacon }); + expect(component.html()).toBeNull(); + }); + + it('renders null when beacon has no location', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent]); + const component = getComponent({ beacon }); + expect(component.html()).toBeNull(); + }); + + describe('when a beacon is live and has locations', () => { + it('renders beacon info', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.html()).toMatchSnapshot(); + }); + + describe('non-self beacons', () => { + it('uses beacon description as beacon name', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('BeaconStatus').props().label).toEqual("Alice's car"); + }); + + it('uses beacon owner mxid as beacon name for a beacon without description', () => { + const [beacon] = setupRoomWithBeacons([pinBeaconWithoutDescription], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('BeaconStatus').props().label).toEqual(aliceId); + }); + + it('renders location icon', () => { + const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('StyledLiveBeaconIcon').length).toBeTruthy(); + }); + }); + + describe('self locations', () => { + it('renders beacon owner avatar', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('MemberAvatar').length).toBeTruthy(); + }); + + it('uses beacon owner name as beacon name', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); + const component = getComponent({ beacon }); + expect(component.find('BeaconStatus').props().label).toEqual('Alice'); + }); + }); + + describe('on location updates', () => { + it('updates last updated time on location updated', () => { + const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation2]); + const component = getComponent({ beacon }); + + expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated 9 minutes ago'); + + // update to a newer location + act(() => { + beacon.addLocations([aliceLocation1]); + component.setProps({}); + }); + + expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago'); + }); + }); + }); +}); diff --git a/test/components/views/beacon/BeaconStatus-test.tsx b/test/components/views/beacon/BeaconStatus-test.tsx index db4153defac..68a6a34a30f 100644 --- a/test/components/views/beacon/BeaconStatus-test.tsx +++ b/test/components/views/beacon/BeaconStatus-test.tsx @@ -26,6 +26,7 @@ describe('', () => { const defaultProps = { displayStatus: BeaconDisplayStatus.Loading, label: 'test label', + withIcon: true, }; const getComponent = (props = {}) => mount(); @@ -40,6 +41,11 @@ describe('', () => { expect(component).toMatchSnapshot(); }); + it('renders without icon', () => { + const component = getComponent({ withIcon: false, displayStatus: BeaconDisplayStatus.Stopped }); + expect(component.find('StyledLiveBeaconIcon').length).toBeFalsy(); + }); + describe('active state', () => { it('renders without children', () => { // mock for stable snapshot diff --git a/test/components/views/beacon/DialogSidebar-test.tsx b/test/components/views/beacon/DialogSidebar-test.tsx index 4c6fe8ad057..a5a1f0e5e79 100644 --- a/test/components/views/beacon/DialogSidebar-test.tsx +++ b/test/components/views/beacon/DialogSidebar-test.tsx @@ -41,7 +41,6 @@ describe('', () => { act(() => { findByTestId(component, 'dialog-sidebar-close').at(0).simulate('click'); }); - expect(requestClose).toHaveBeenCalled(); }); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap new file mode 100644 index 00000000000..1518a60dba9 --- /dev/null +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Alice's carLive until 16:04
    Updated a few seconds ago
  • "`; diff --git a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap index 5e2b6673daa..b3366336a17 100644 --- a/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconStatus-test.tsx.snap @@ -43,6 +43,7 @@ exports[` active state renders without children 1`] = ` } displayStatus="Active" label="test label" + withIcon={true} >
    active state renders without children 1`] = `
    - test label + + test label + renders loading state 1`] = `
    renders stopped state 1`] = `
    renders without a beacon instance 1`] = ` displayLiveTimeRemaining={true} displayStatus="Loading" label="Live location enabled" + withIcon={true} >
    Date: Wed, 20 Apr 2022 15:49:12 +0100 Subject: [PATCH 4/4] fix rainbow breaks compound emojis (#8245) * fix rainbow breaks compound emojis * use lodash split method for text splitting and add a unit test to test the behaviour * Update _RichText.scss * Update colour-test.ts --- src/utils/colour.ts | 4 +++- test/utils/colour-test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 test/utils/colour-test.ts diff --git a/src/utils/colour.ts b/src/utils/colour.ts index 10c18dbfe76..96eabd4eb40 100644 --- a/src/utils/colour.ts +++ b/src/utils/colour.ts @@ -14,10 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { split } from 'lodash'; + export function textToHtmlRainbow(str: string): string { const frequency = (2 * Math.PI) / str.length; - return Array.from(str) + return split(str, '') .map((c, i) => { if (c === " ") { return c; diff --git a/test/utils/colour-test.ts b/test/utils/colour-test.ts new file mode 100644 index 00000000000..720c34e07bb --- /dev/null +++ b/test/utils/colour-test.ts @@ -0,0 +1,24 @@ +/* +Copyright 2022 Emmanuel Ezeka + +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 { textToHtmlRainbow } from "../../src/utils/colour"; + +describe("textToHtmlRainbow", () => { + it('correctly transform text to html without splitting the emoji in two', () => { + expect(textToHtmlRainbow('🐻')).toBe('🐻'); + expect(textToHtmlRainbow('🐕‍🦺')).toBe('🐕‍🦺'); + }); +});